├── LICENSE ├── composer.json └── src ├── Bytes.php ├── DataType.php ├── Dictionary.php ├── DisplayString.php ├── ForbiddenOperation.php ├── Ietf.php ├── InnerList.php ├── InvalidArgument.php ├── InvalidOffset.php ├── Item.php ├── Key.php ├── Member.php ├── MissingFeature.php ├── OuterList.php ├── ParameterAccess.php ├── Parameters.php ├── Parser.php ├── StructuredFieldError.php ├── StructuredFieldProvider.php ├── SyntaxError.php ├── Token.php ├── Type.php └── Validation ├── ErrorCode.php ├── ItemValidator.php ├── ParametersValidator.php ├── Result.php ├── ValidatedItem.php ├── ValidatedParameters.php ├── Violation.php └── ViolationList.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Nyamagana Butera 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bakame/http-structured-fields", 3 | "description": "A PHP library that parses, validates and serializes HTTP structured fields according to RFC9561 and RFC8941", 4 | "type": "library", 5 | "keywords": [ 6 | "http", 7 | "http headers", 8 | "http trailers", 9 | "headers", 10 | "trailers", 11 | "structured fields", 12 | "structured headers", 13 | "structured trailers", 14 | "structured values", 15 | "parser", 16 | "serializer", 17 | "validation", 18 | "rfc8941", 19 | "rfc9651" 20 | ], 21 | "license": "MIT", 22 | "authors": [ 23 | { 24 | "name" : "Ignace Nyamagana Butera", 25 | "email" : "nyamsprod@gmail.com", 26 | "homepage" : "https://github.com/nyamsprod/", 27 | "role" : "Developer" 28 | } 29 | ], 30 | "support": { 31 | "docs": "https://github.com/bakame-php/http-structured-fields", 32 | "issues": "https://github.com/bakame-php/http-structured-fields/issues", 33 | "source": "https://github.com/bakame-php/http-structured-fields" 34 | }, 35 | "funding": [ 36 | { 37 | "type": "github", 38 | "url": "https://github.com/sponsors/nyamsprod" 39 | } 40 | ], 41 | "require": { 42 | "php" : "^8.1" 43 | }, 44 | "require-dev": { 45 | "friendsofphp/php-cs-fixer": "^3.65.0", 46 | "httpwg/structured-field-tests": "*@dev", 47 | "phpstan/phpstan": "^2.0.3", 48 | "phpstan/phpstan-strict-rules": "^2.0", 49 | "phpstan/phpstan-phpunit": "^2.0.1", 50 | "phpstan/phpstan-deprecation-rules": "^2.0.1", 51 | "phpunit/phpunit": "^10.5.38 || ^11.5.0", 52 | "symfony/var-dumper": "^6.4.15 || ^v7.2.0", 53 | "bakame/aide-base32": "dev-main", 54 | "phpbench/phpbench": "^1.3.1" 55 | }, 56 | "autoload": { 57 | "psr-4": { 58 | "Bakame\\Http\\StructuredFields\\": "src/" 59 | } 60 | }, 61 | "autoload-dev": { 62 | "psr-4": { 63 | "Bakame\\Http\\StructuredFields\\": "tests/" 64 | } 65 | }, 66 | "scripts": { 67 | "benchmark": "phpbench run --report=default", 68 | "phpcs": "php-cs-fixer fix --dry-run --diff -vvv --allow-risky=yes --ansi", 69 | "phpcs:fix": "php-cs-fixer fix -vvv --allow-risky=yes --ansi", 70 | "phpstan": "phpstan analyse -c phpstan.neon --ansi --memory-limit 192M", 71 | "phpunit": "XDEBUG_MODE=coverage phpunit --coverage-text", 72 | "phpunit:min": "phpunit --no-coverage", 73 | "test": [ 74 | "@phpunit", 75 | "@phpstan", 76 | "@phpcs" 77 | ] 78 | }, 79 | "scripts-descriptions": { 80 | "benchmark": "Runs parser benchmark", 81 | "phpstan": "Runs complete codebase static analysis", 82 | "phpunit": "Runs unit and functional testing", 83 | "phpcs": "Runs coding style testing", 84 | "phpcs:fix": "Fix coding style issues", 85 | "test": "Runs all tests" 86 | }, 87 | "repositories": [ 88 | { 89 | "type": "package", 90 | "package": { 91 | "name": "httpwg/structured-field-tests", 92 | "version": "dev-main", 93 | "source": { 94 | "url": "https://github.com/httpwg/structured-field-tests.git", 95 | "type": "git", 96 | "reference": "main" 97 | } 98 | } 99 | } 100 | ], 101 | "extra": { 102 | "branch-alias": { 103 | "dev-develop": "1.x-dev" 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Bytes.php: -------------------------------------------------------------------------------- 1 | value; 64 | } 65 | 66 | /** 67 | * Returns the base64 encoded string. 68 | */ 69 | public function encoded(): string 70 | { 71 | return base64_encode($this->value); 72 | } 73 | 74 | public function equals(mixed $other): bool 75 | { 76 | return $other instanceof self && $other->value === $this->value; 77 | } 78 | 79 | public function type(): Type 80 | { 81 | return Type::Bytes; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/DataType.php: -------------------------------------------------------------------------------- 1 | OuterList::fromHttpValue($httpValue, $rfc), 25 | self::Dictionary => Dictionary::fromHttpValue($httpValue, $rfc), 26 | self::Item => Item::fromHttpValue($httpValue, $rfc), 27 | self::InnerList => InnerList::fromHttpValue($httpValue, $rfc), 28 | self::Parameters => Parameters::fromHttpValue($httpValue, $rfc), 29 | }; 30 | } 31 | 32 | /** 33 | * @throws SyntaxError|Exception 34 | */ 35 | public function serialize(iterable $data, Ietf $rfc = Ietf::Rfc9651): string 36 | { 37 | return (match ($this) { 38 | self::List => OuterList::fromPairs($data), 39 | self::Dictionary => Dictionary::fromPairs($data), 40 | self::Item => Item::fromPair([...$data]), 41 | self::InnerList => InnerList::fromPair([...$data]), 42 | self::Parameters => Parameters::fromPairs($data), 43 | })->toHttpValue($rfc); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Dictionary.php: -------------------------------------------------------------------------------- 1 | 35 | * @implements IteratorAggregate 36 | */ 37 | final class Dictionary implements ArrayAccess, Countable, IteratorAggregate 38 | { 39 | /** @var array */ 40 | private readonly array $members; 41 | 42 | /** 43 | * @param iterable $members 44 | */ 45 | private function __construct(iterable $members = []) 46 | { 47 | $filteredMembers = []; 48 | foreach ($members as $key => $member) { 49 | $filteredMembers[Key::from($key)->value] = Member::innerListOrItem($member); 50 | } 51 | 52 | $this->members = $filteredMembers; 53 | } 54 | 55 | /** 56 | * Returns a new instance. 57 | */ 58 | public static function new(): self 59 | { 60 | return new self(); 61 | } 62 | 63 | /** 64 | * Returns a new instance from an associative iterable construct. 65 | * 66 | * its keys represent the dictionary entry name 67 | * its values represent the dictionary entry value 68 | * 69 | * @param StructuredFieldProvider|iterable $members 70 | */ 71 | public static function fromAssociative(StructuredFieldProvider|iterable $members): self 72 | { 73 | if ($members instanceof StructuredFieldProvider) { 74 | $structuredField = $members->toStructuredField(); 75 | 76 | return match (true) { 77 | $structuredField instanceof Dictionary, 78 | $structuredField instanceof Parameters => new self($structuredField->toAssociative()), 79 | default => throw new InvalidArgument('The '.StructuredFieldProvider::class.' must provide a structured field container; '.$structuredField::class.' given.'), 80 | }; 81 | } 82 | 83 | return new self($members); 84 | } 85 | 86 | /** 87 | * Returns a new instance from a pair iterable construct. 88 | * 89 | * Each member is composed of an array with two elements 90 | * the first member represents the instance entry key 91 | * the second member represents the instance entry value 92 | * 93 | * @param StructuredFieldProvider|Dictionary|Parameters|iterable $pairs 94 | */ 95 | public static function fromPairs(StructuredFieldProvider|Dictionary|Parameters|iterable $pairs): self 96 | { 97 | if ($pairs instanceof StructuredFieldProvider) { 98 | $pairs = $pairs->toStructuredField(); 99 | } 100 | 101 | if (!is_iterable($pairs)) { 102 | throw new InvalidArgument('The "'.$pairs::class.'" instance can not be used for creating a .'.self::class.' structured field.'); 103 | } 104 | 105 | return match (true) { 106 | $pairs instanceof Dictionary, 107 | $pairs instanceof Parameters => new self($pairs->toAssociative()), 108 | default => new self((function (iterable $pairs) { 109 | foreach ($pairs as [$key, $member]) { 110 | yield $key => Member::innerListOrItemFromPair($member); 111 | } 112 | })($pairs)), 113 | }; 114 | } 115 | 116 | /** 117 | * Returns an instance from an HTTP textual representation compliant with RFC9651. 118 | * 119 | * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-3.2 120 | * 121 | * @throws StructuredFieldError|Throwable 122 | */ 123 | public static function fromRfc9651(Stringable|string $httpValue): self 124 | { 125 | return self::fromHttpValue($httpValue, Ietf::Rfc9651); 126 | } 127 | 128 | /** 129 | * Returns an instance from an HTTP textual representation compliant with RFC8941. 130 | * 131 | * @see https://www.rfc-editor.org/rfc/rfc8941.html#section-3.2 132 | * 133 | * @throws StructuredFieldError|Throwable 134 | */ 135 | public static function fromRfc8941(Stringable|string $httpValue): self 136 | { 137 | return self::fromHttpValue($httpValue, Ietf::Rfc8941); 138 | } 139 | 140 | /** 141 | * Returns an instance from an HTTP textual representation. 142 | * 143 | * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-3.2 144 | * 145 | * @throws StructuredFieldError|Exception If the string is not a valid 146 | */ 147 | public static function fromHttpValue(Stringable|string $httpValue, Ietf $rfc = Ietf::Rfc9651): self 148 | { 149 | return self::fromPairs((new Parser($rfc))->parseDictionary($httpValue)); /* @phpstan-ignore-line */ 150 | } 151 | 152 | public function toRfc9651(): string 153 | { 154 | return $this->toHttpValue(Ietf::Rfc9651); 155 | } 156 | 157 | public function toRfc8941(): string 158 | { 159 | return $this->toHttpValue(Ietf::Rfc8941); 160 | } 161 | 162 | public function toHttpValue(Ietf $rfc = Ietf::Rfc9651): string 163 | { 164 | $formatter = static fn (Item|InnerList $member, string $offset): string => match (true) { 165 | $member instanceof Item && true === $member->value() => $offset.$member->parameters()->toHttpValue($rfc), 166 | default => $offset.'='.$member->toHttpValue($rfc), 167 | }; 168 | 169 | return implode(', ', array_map($formatter, $this->members, array_keys($this->members))); 170 | } 171 | 172 | public function __toString(): string 173 | { 174 | return $this->toHttpValue(); 175 | } 176 | 177 | public function equals(mixed $other): bool 178 | { 179 | return $other instanceof self && $other->toHttpValue() === $this->toHttpValue(); 180 | } 181 | 182 | /** 183 | * Apply the callback if the given "condition" is (or resolves to) true. 184 | * 185 | * @param (callable($this): bool)|bool $condition 186 | * @param callable($this): (self|null) $onSuccess 187 | * @param ?callable($this): (self|null) $onFail 188 | */ 189 | public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): self 190 | { 191 | if (!is_bool($condition)) { 192 | $condition = $condition($this); 193 | } 194 | 195 | return match (true) { 196 | $condition => $onSuccess($this), 197 | null !== $onFail => $onFail($this), 198 | default => $this, 199 | } ?? $this; 200 | } 201 | 202 | public function count(): int 203 | { 204 | return count($this->members); 205 | } 206 | 207 | /** 208 | * Tells whether the instance contains no members. 209 | */ 210 | public function isEmpty(): bool 211 | { 212 | return !$this->isNotEmpty(); 213 | } 214 | 215 | /** 216 | * Tells whether the instance contains any members. 217 | */ 218 | public function isNotEmpty(): bool 219 | { 220 | return [] !== $this->members; 221 | } 222 | 223 | /** 224 | * @return Iterator 225 | */ 226 | public function toAssociative(): Iterator 227 | { 228 | yield from $this->members; 229 | } 230 | 231 | /** 232 | * Returns an iterable construct of dictionary pairs. 233 | * 234 | * @return Iterator 235 | */ 236 | public function getIterator(): Iterator 237 | { 238 | foreach ($this->members as $index => $member) { 239 | yield [$index, $member]; 240 | } 241 | } 242 | 243 | /** 244 | * Returns an ordered list of the instance keys. 245 | * 246 | * @return array 247 | */ 248 | public function keys(): array 249 | { 250 | return array_keys($this->members); 251 | } 252 | 253 | /** 254 | * @return array 255 | */ 256 | public function indices(): array 257 | { 258 | return array_keys($this->keys()); 259 | } 260 | 261 | /** 262 | * Tells whether the instance contain a members at the specified offsets. 263 | */ 264 | public function hasKeys(string ...$keys): bool 265 | { 266 | foreach ($keys as $key) { 267 | if (!array_key_exists($key, $this->members)) { 268 | return false; 269 | } 270 | } 271 | 272 | return [] !== $keys; 273 | } 274 | 275 | /** 276 | * Returns true only if the instance only contains the listed keys, false otherwise. 277 | * 278 | * @param array $keys 279 | */ 280 | public function allowedKeys(array $keys): bool 281 | { 282 | foreach ($this->members as $key => $member) { 283 | if (!in_array($key, $keys, true)) { 284 | return false; 285 | } 286 | } 287 | 288 | return [] !== $keys; 289 | } 290 | 291 | /** 292 | * @param ?callable(Item|InnerList): (bool|string) $validate 293 | * 294 | * @throws InvalidOffset|Violation|StructuredFieldError 295 | */ 296 | public function getByKey(string $key, ?callable $validate = null): Item|InnerList 297 | { 298 | $value = $this->members[$key] ?? throw InvalidOffset::dueToKeyNotFound($key); 299 | if (null === $validate || true === ($exceptionMessage = $validate($value))) { 300 | return $value; 301 | } 302 | 303 | if (!is_string($exceptionMessage) || '' === trim($exceptionMessage)) { 304 | $exceptionMessage = "The parameter '{key}' whose value is '{value}' failed validation."; 305 | } 306 | 307 | throw new Violation(strtr($exceptionMessage, ['{key}' => $key, '{value}' => $value->toHttpValue()])); 308 | } 309 | 310 | /** 311 | * Tells whether a pair is attached to the given index position. 312 | */ 313 | public function hasIndices(int ...$indexes): bool 314 | { 315 | $max = count($this->members); 316 | foreach ($indexes as $index) { 317 | if (null === $this->filterIndex($index, $max)) { 318 | return false; 319 | } 320 | } 321 | 322 | return [] !== $indexes; 323 | } 324 | 325 | /** 326 | * Filters and format instance index. 327 | */ 328 | private function filterIndex(int $index, ?int $max = null): ?int 329 | { 330 | $max ??= count($this->members); 331 | 332 | return match (true) { 333 | [] === $this->members, 334 | 0 > $max + $index, 335 | 0 > $max - $index - 1 => null, 336 | 0 > $index => $max + $index, 337 | default => $index, 338 | }; 339 | } 340 | 341 | /** 342 | * Returns the item or the inner-list and its key as attached to the given 343 | * collection according to their index position otherwise throw. 344 | * 345 | * @param ?callable(Item|InnerList, string): (bool|string) $validate 346 | * 347 | * @throws InvalidOffset|Violation|StructuredFieldError 348 | * 349 | * @return array{0:string, 1:InnerList|Item} 350 | */ 351 | public function getByIndex(int $index, ?callable $validate = null): array 352 | { 353 | $foundOffset = $this->filterIndex($index) ?? throw InvalidOffset::dueToIndexNotFound($index); 354 | foreach ($this as $offset => $pair) { 355 | if ($offset === $foundOffset) { 356 | break; 357 | } 358 | } 359 | 360 | if (!isset($pair)) { 361 | throw InvalidOffset::dueToIndexNotFound($index); 362 | } 363 | 364 | if (null === $validate || true === ($exceptionMessage = $validate($pair[1], $pair[0]))) { 365 | return $pair; 366 | } 367 | 368 | if (!is_string($exceptionMessage) || '' === trim($exceptionMessage)) { 369 | $exceptionMessage = "The member at position '{index}' whose key is '{key}' with the value '{value}' failed validation."; 370 | } 371 | 372 | throw new Violation(strtr($exceptionMessage, ['{index}' => $index, '{key}' => $pair[0], '{value}' => $pair[1]->toHttpValue()])); 373 | } 374 | 375 | /** 376 | * Returns the key associated with the given index or null otherwise. 377 | */ 378 | public function indexByKey(string $key): ?int 379 | { 380 | foreach ($this as $index => $member) { 381 | if ($key === $member[0]) { 382 | return $index; 383 | } 384 | } 385 | 386 | return null; 387 | } 388 | 389 | /** 390 | * Returns the index associated with the given key or null otherwise. 391 | */ 392 | public function keyByIndex(int $index): ?string 393 | { 394 | $index = $this->filterIndex($index); 395 | if (null === $index) { 396 | return null; 397 | } 398 | 399 | foreach ($this as $offset => $member) { 400 | if ($offset === $index) { 401 | return $member[0]; 402 | } 403 | } 404 | 405 | return null; 406 | } 407 | 408 | /** 409 | * Returns the first member whether it is an item or an inner-list and its key as attached to the given 410 | * collection according to their index position otherwise returns an empty array. 411 | * 412 | * @return array{0:string, 1:InnerList|Item}|array{} 413 | */ 414 | public function first(): array 415 | { 416 | try { 417 | return $this->getByIndex(0); 418 | } catch (StructuredFieldError) { 419 | return []; 420 | } 421 | } 422 | 423 | /** 424 | * Returns the first member whether it is an item or an inner-list and its key as attached to the given 425 | * collection according to their index position otherwise returns an empty array. 426 | * 427 | * @return array{0:string, 1:InnerList|Item}|array{} 428 | */ 429 | public function last(): array 430 | { 431 | try { 432 | return $this->getByIndex(-1); 433 | } catch (StructuredFieldError) { 434 | return []; 435 | } 436 | } 437 | 438 | /** 439 | * Adds a member at the end of the instance otherwise updates the value associated with the key if already present. 440 | * 441 | * This method MUST retain the state of the current instance, and return 442 | * an instance that contains the specified changes. 443 | * 444 | * @param SfMemberInput $member 445 | * 446 | * @throws SyntaxError If the string key is not a valid 447 | */ 448 | public function add( 449 | string $key, 450 | iterable|StructuredFieldProvider|Dictionary|Parameters|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool $member 451 | ): self { 452 | $members = $this->members; 453 | $members[Key::from($key)->value] = Member::innerListOrItem($member); 454 | 455 | return $this->newInstance($members); 456 | } 457 | 458 | /** 459 | * @param array $members 460 | */ 461 | private function newInstance(array $members): self 462 | { 463 | foreach ($members as $offset => $member) { 464 | if (!isset($this->members[$offset]) || !$this->members[$offset]->equals($member)) { 465 | return new self($members); 466 | } 467 | } 468 | 469 | return $this; 470 | } 471 | 472 | /** 473 | * Deletes members associated with the list of submitted keys. 474 | * 475 | * This method MUST retain the state of the current instance, and return 476 | * an instance that contains the specified changes. 477 | */ 478 | private function remove(string|int ...$offsets): self 479 | { 480 | if ([] === $this->members || [] === $offsets) { 481 | return $this; 482 | } 483 | 484 | $keys = array_keys($this->members); 485 | $max = count($keys); 486 | $reducer = fn (array $carry, string|int $key): array => match (true) { 487 | is_string($key) && (false !== ($position = array_search($key, $keys, true))), 488 | is_int($key) && (null !== ($position = $this->filterIndex($key, $max))) => [$position => true] + $carry, 489 | default => $carry, 490 | }; 491 | 492 | $indices = array_reduce($offsets, $reducer, []); 493 | 494 | return match (true) { 495 | [] === $indices => $this, 496 | $max === count($indices) => self::new(), 497 | default => self::fromPairs((function (array $offsets) { 498 | foreach ($this->getIterator() as $offset => $pair) { 499 | if (!array_key_exists($offset, $offsets)) { 500 | yield $pair; 501 | } 502 | } 503 | })($indices)), 504 | }; 505 | } 506 | 507 | /** 508 | * Deletes members associated with the list using the member pair offset. 509 | * 510 | * This method MUST retain the state of the current instance, and return 511 | * an instance that contains the specified changes. 512 | */ 513 | public function removeByIndices(int ...$indices): self 514 | { 515 | return $this->remove(...$indices); 516 | } 517 | 518 | /** 519 | * Deletes members associated with the list using the member key. 520 | * 521 | * This method MUST retain the state of the current instance, and return 522 | * an instance that contains the specified changes. 523 | */ 524 | public function removeByKeys(string ...$keys): self 525 | { 526 | return $this->remove(...$keys); 527 | } 528 | 529 | /** 530 | * Adds a member at the end of the instance and deletes any previous reference to the key if present. 531 | * 532 | * This method MUST retain the state of the current instance, and return 533 | * an instance that contains the specified changes. 534 | * 535 | * @param SfMemberInput $member 536 | * @throws SyntaxError If the string key is not a valid 537 | */ 538 | public function append( 539 | string $key, 540 | iterable|StructuredFieldProvider|Dictionary|Parameters|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool $member 541 | ): self { 542 | $members = $this->members; 543 | unset($members[$key]); 544 | 545 | return $this->newInstance([...$members, Key::from($key)->value => Member::innerListOrItem($member)]); 546 | } 547 | 548 | /** 549 | * Adds a member at the beginning of the instance and deletes any previous reference to the key if present. 550 | * 551 | * This method MUST retain the state of the current instance, and return 552 | * an instance that contains the specified changes. 553 | * 554 | * @param SfMemberInput $member 555 | * 556 | * @throws SyntaxError If the string key is not a valid 557 | */ 558 | public function prepend( 559 | string $key, 560 | iterable|StructuredFieldProvider|Dictionary|Parameters|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool $member 561 | ): self { 562 | $members = $this->members; 563 | unset($members[$key]); 564 | 565 | return $this->newInstance([Key::from($key)->value => Member::innerListOrItem($member), ...$members]); 566 | } 567 | 568 | /** 569 | * Inserts pairs at the end of the container. 570 | * 571 | * This method MUST retain the state of the current instance, and return 572 | * an instance that contains the specified changes. 573 | * 574 | * @param array{0:string, 1:SfMemberInput} ...$pairs 575 | */ 576 | public function push(array ...$pairs): self 577 | { 578 | return match (true) { 579 | [] === $pairs => $this, 580 | default => self::fromPairs((function (iterable $pairs) { 581 | yield from $this->getIterator(); 582 | yield from $pairs; 583 | })($pairs)), 584 | }; 585 | } 586 | 587 | /** 588 | * Inserts pairs at the beginning of the container. 589 | * 590 | * This method MUST retain the state of the current instance, and return 591 | * an instance that contains the specified changes. 592 | * 593 | * @param array{0:string, 1:SfMemberInput} ...$pairs 594 | */ 595 | public function unshift(array ...$pairs): self 596 | { 597 | return match (true) { 598 | [] === $pairs => $this, 599 | default => self::fromPairs((function (iterable $pairs) { 600 | yield from $pairs; 601 | yield from $this->getIterator(); 602 | })($pairs)), 603 | }; 604 | } 605 | 606 | /** 607 | * Insert a member pair using its offset. 608 | * 609 | * This method MUST retain the state of the current instance, and return 610 | * an instance that contains the specified changes. 611 | * 612 | * @param array{0:string, 1:SfMemberInput} ...$members 613 | */ 614 | public function insert(int $index, array ...$members): self 615 | { 616 | $offset = $this->filterIndex($index) ?? throw InvalidOffset::dueToIndexNotFound($index); 617 | 618 | return match (true) { 619 | [] === $members => $this, 620 | 0 === $offset => $this->unshift(...$members), 621 | count($this->members) === $offset => $this->push(...$members), 622 | default => (function (Iterator $newMembers) use ($offset, $members) { 623 | $newMembers = iterator_to_array($newMembers); 624 | array_splice($newMembers, $offset, 0, $members); 625 | 626 | return self::fromPairs($newMembers); 627 | })($this->getIterator()), 628 | }; 629 | } 630 | 631 | /** 632 | * Replace a member pair using its offset. 633 | * 634 | * This method MUST retain the state of the current instance, and return 635 | * an instance that contains the specified changes. 636 | * 637 | * @param array{0:string, 1:SfMemberInput} $pair 638 | */ 639 | public function replace(int $index, array $pair): self 640 | { 641 | $offset = $this->filterIndex($index) ?? throw InvalidOffset::dueToIndexNotFound($index); 642 | $pair[1] = Member::innerListOrItem($pair[1]); 643 | $pairs = iterator_to_array($this->getIterator()); 644 | 645 | return match (true) { 646 | $pairs[$offset][0] === $pair[0] && $pairs[$offset][1]->equals($pair[1]) => $this, 647 | default => self::fromPairs(array_replace($pairs, [$offset => $pair])), 648 | }; 649 | } 650 | 651 | /** 652 | * Merges multiple instances using iterable associative structures. 653 | * 654 | * This method MUST retain the state of the current instance, and return 655 | * an instance that contains the specified changes. 656 | * 657 | * @param StructuredFieldProvider|Dictionary|Parameters|iterable ...$others 658 | */ 659 | public function mergeAssociative(StructuredFieldProvider|iterable ...$others): self 660 | { 661 | $members = $this->members; 662 | foreach ($others as $other) { 663 | if ($other instanceof StructuredFieldProvider) { 664 | $other = $other->toStructuredField(); 665 | if (!is_iterable($other)) { 666 | throw new InvalidArgument('The "'.$other::class.'" instance can not be used for creating a .'.self::class.' structured field.'); 667 | } 668 | } 669 | 670 | if ($other instanceof self || $other instanceof Parameters) { 671 | $other = $other->toAssociative(); 672 | } 673 | 674 | foreach ($other as $key => $value) { 675 | $members[$key] = $value; 676 | } 677 | } 678 | 679 | return new self($members); 680 | } 681 | 682 | /** 683 | * Merges multiple instances using iterable pairs. 684 | * 685 | * This method MUST retain the state of the current instance, and return 686 | * an instance that contains the specified changes. 687 | * 688 | * @param StructuredFieldProvider|Dictionary|Parameters|iterable ...$others 689 | */ 690 | public function mergePairs(StructuredFieldProvider|Dictionary|Parameters|iterable ...$others): self 691 | { 692 | $members = $this->members; 693 | foreach ($others as $other) { 694 | if (!$other instanceof self) { 695 | $other = self::fromPairs($other); 696 | } 697 | foreach ($other->toAssociative() as $key => $value) { 698 | $members[$key] = $value; 699 | } 700 | } 701 | 702 | return new self($members); 703 | } 704 | 705 | /** 706 | * @param string $offset 707 | */ 708 | public function offsetExists(mixed $offset): bool 709 | { 710 | return $this->hasKeys($offset); 711 | } 712 | 713 | /** 714 | * @param string $offset 715 | * 716 | * @throws StructuredFieldError 717 | */ 718 | public function offsetGet(mixed $offset): InnerList|Item 719 | { 720 | return $this->getByKey($offset); 721 | } 722 | 723 | public function offsetUnset(mixed $offset): void 724 | { 725 | throw new ForbiddenOperation(self::class.' instance can not be updated using '.ArrayAccess::class.' methods.'); 726 | } 727 | 728 | public function offsetSet(mixed $offset, mixed $value): void 729 | { 730 | throw new ForbiddenOperation(self::class.' instance can not be updated using '.ArrayAccess::class.' methods.'); 731 | } 732 | 733 | /** 734 | * Run a map over each container members. 735 | * 736 | * @template TMap 737 | * 738 | * @param callable(array{0:string, 1:Item|InnerList}, int): TMap $callback 739 | * 740 | * @return Iterator 741 | */ 742 | public function map(callable $callback): Iterator 743 | { 744 | foreach ($this as $offset => $member) { 745 | yield ($callback)($member, $offset); 746 | } 747 | } 748 | 749 | /** 750 | * @param callable(TInitial|null, array{0:string, 1:Item|InnerList}, int): TInitial $callback 751 | * @param TInitial|null $initial 752 | * 753 | * @template TInitial 754 | * 755 | * @return TInitial|null 756 | */ 757 | public function reduce(callable $callback, mixed $initial = null): mixed 758 | { 759 | foreach ($this as $offset => $pair) { 760 | $initial = $callback($initial, $pair, $offset); 761 | } 762 | 763 | return $initial; 764 | } 765 | 766 | /** 767 | * Run a filter over each container members. 768 | * 769 | * @param callable(array{0:string, 1:InnerList|Item}, int): bool $callback 770 | */ 771 | public function filter(callable $callback): self 772 | { 773 | return self::fromPairs(new CallbackFilterIterator($this, $callback)); 774 | } 775 | 776 | /** 777 | * Sort a container by value using a callback. 778 | * 779 | * @param callable(array{0:string, 1:InnerList|Item}, array{0:string, 1:InnerList|Item}): int $callback 780 | */ 781 | public function sort(callable $callback): self 782 | { 783 | $members = iterator_to_array($this); 784 | uasort($members, $callback); 785 | 786 | return self::fromPairs($members); 787 | } 788 | } 789 | -------------------------------------------------------------------------------- /src/DisplayString.php: -------------------------------------------------------------------------------- 1 | rawurldecode($matches[0]), 56 | $encoded 57 | ); 58 | 59 | if (1 !== preg_match('//u', $decoded)) { 60 | throw new SyntaxError('The display string '.$encoded.' contains invalid characters.'); 61 | } 62 | 63 | return new self($decoded); 64 | } 65 | 66 | /** 67 | * Returns a new instance from a raw decoded string. 68 | */ 69 | public static function fromDecoded(Stringable|string $decoded): self 70 | { 71 | return new self((string) $decoded); 72 | } 73 | 74 | /** 75 | * Returns the decoded string. 76 | */ 77 | public function decoded(): string 78 | { 79 | return $this->value; 80 | } 81 | 82 | /** 83 | * Returns the base64 encoded string. 84 | */ 85 | public function encoded(): string 86 | { 87 | return (string) preg_replace_callback( 88 | '/[%"\x00-\x1F\x7F-\xFF]/', 89 | static fn (array $matches): string => strtolower(rawurlencode($matches[0])), 90 | $this->value 91 | ); 92 | } 93 | 94 | public function equals(mixed $other): bool 95 | { 96 | return $other instanceof self && $other->value === $this->value; 97 | } 98 | 99 | public function type(): Type 100 | { 101 | return Type::DisplayString; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/ForbiddenOperation.php: -------------------------------------------------------------------------------- 1 | 'https://www.rfc-editor.org/rfc/rfc9651.html', 19 | self::Rfc8941 => 'https://www.rfc-editor.org/rfc/rfc8941.html', 20 | }; 21 | } 22 | 23 | public function publishedAt(): DateTimeImmutable 24 | { 25 | return new DateTimeImmutable(match ($this) { 26 | self::Rfc9651 => '2024-09-01', 27 | self::Rfc8941 => '2021-02-01', 28 | }, new DateTimeZone('UTC')); 29 | } 30 | 31 | public function isActive(): bool 32 | { 33 | return self::Rfc9651 === $this; 34 | } 35 | 36 | public function isObsolete(): bool 37 | { 38 | return !$this->isActive(); 39 | } 40 | 41 | public function supports(mixed $value): bool 42 | { 43 | if ($value instanceof StructuredFieldProvider) { 44 | $value = $value->toStructuredField(); 45 | } 46 | 47 | if ($value instanceof OuterList || 48 | $value instanceof InnerList || 49 | $value instanceof Dictionary || 50 | $value instanceof Parameters || 51 | $value instanceof Item 52 | ) { 53 | try { 54 | $value->toHttpValue($this); 55 | 56 | return true; 57 | } catch (MissingFeature) { 58 | return false; 59 | } 60 | } 61 | 62 | if (!$value instanceof Type) { 63 | $value = Type::tryFromVariable($value); 64 | } 65 | 66 | return match ($value) { 67 | null => false, 68 | Type::DisplayString, 69 | Type::Date => self::Rfc8941 !== $this, 70 | default => true, 71 | }; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/InnerList.php: -------------------------------------------------------------------------------- 1 | 39 | * @implements IteratorAggregate 40 | */ 41 | final class InnerList implements ArrayAccess, Countable, IteratorAggregate 42 | { 43 | use ParameterAccess; 44 | 45 | /** @var list */ 46 | private readonly array $members; 47 | private readonly Parameters $parameters; 48 | 49 | /** 50 | * @param iterable $members 51 | */ 52 | private function __construct(iterable $members, ?Parameters $parameters = null) 53 | { 54 | $this->members = array_map(Member::item(...), array_values([...$members])); 55 | $this->parameters = $parameters ?? Parameters::new(); 56 | } 57 | 58 | /** 59 | * Returns an instance from an HTTP textual representation. 60 | * 61 | * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-3.1 62 | */ 63 | public static function fromHttpValue(Stringable|string $httpValue, Ietf $rfc = Ietf::Rfc9651): self 64 | { 65 | return self::fromPair((new Parser($rfc))->parseInnerList($httpValue)); 66 | } 67 | 68 | /** 69 | * Returns a new instance with an iter. 70 | * 71 | * @param iterable $value 72 | * @param Parameters|iterable $parameters 73 | */ 74 | public static function fromAssociative( 75 | iterable $value, 76 | StructuredFieldProvider|Parameters|iterable $parameters 77 | ): self { 78 | if ($parameters instanceof StructuredFieldProvider) { 79 | $parameters = $parameters->toStructuredField(); 80 | if (!$parameters instanceof Parameters) { 81 | throw new InvalidArgument('The '.StructuredFieldProvider::class.' must provide a '.Parameters::class.'; '.$parameters::class.' given.'); 82 | } 83 | } 84 | 85 | if (!$parameters instanceof Parameters) { 86 | return new self($value, Parameters::fromAssociative($parameters)); 87 | } 88 | 89 | return new self($value, $parameters); 90 | } 91 | 92 | /** 93 | * @param array{0:iterable, 1?:Parameters|SfParameterInput}|array $pair 94 | */ 95 | public static function fromPair(array $pair = []): self 96 | { 97 | if ([] === $pair) { 98 | return self::new(); 99 | } 100 | 101 | if (!array_is_list($pair) || 2 < count($pair)) { 102 | throw new SyntaxError('The pair must be represented by an non-empty array as a list containing at most 2 members.'); 103 | } 104 | 105 | if (1 === count($pair)) { 106 | return new self($pair[0]); 107 | } 108 | 109 | if ($pair[1] instanceof StructuredFieldProvider) { 110 | $pair[1] = $pair[1]->toStructuredField(); 111 | if (!$pair[1] instanceof Parameters) { 112 | throw new InvalidArgument('The '.StructuredFieldProvider::class.' must provide a '.Parameters::class.'; '.$pair[1]::class.' given.'); 113 | } 114 | } 115 | 116 | if (!$pair[1] instanceof Parameters) { 117 | return new self($pair[0], Parameters::fromPairs($pair[1])); 118 | } 119 | 120 | return new self($pair[0], $pair[1]); 121 | } 122 | 123 | /** 124 | * Returns a new instance. 125 | * 126 | * @param StructuredFieldProvider|Item|SfTypeInput|SfItemPair ...$members 127 | */ 128 | public static function new( 129 | StructuredFieldProvider|Item|Token|Bytes|DisplayString|DateTimeInterface|array|string|int|float|bool ...$members 130 | ): self { 131 | return new self($members); 132 | } 133 | 134 | public static function fromRfc9651(Stringable|string $httpValue): self 135 | { 136 | return self::fromHttpValue($httpValue, Ietf::Rfc9651); 137 | } 138 | 139 | public static function fromRfc8941(Stringable|string $httpValue): self 140 | { 141 | return self::fromHttpValue($httpValue, Ietf::Rfc8941); 142 | } 143 | 144 | public function toHttpValue(Ietf $rfc = Ietf::Rfc9651): string 145 | { 146 | return '('.implode(' ', array_map(fn (Item $value): string => $value->toHttpValue($rfc), $this->members)).')'.$this->parameters->toHttpValue($rfc); 147 | } 148 | 149 | public function toRfc9651(): string 150 | { 151 | return $this->toHttpValue(Ietf::Rfc9651); 152 | } 153 | 154 | public function toRfc8941(): string 155 | { 156 | return $this->toHttpValue(Ietf::Rfc8941); 157 | } 158 | 159 | public function __toString(): string 160 | { 161 | return $this->toHttpValue(); 162 | } 163 | 164 | public function equals(mixed $other): bool 165 | { 166 | return $other instanceof self && $other->toHttpValue() === $this->toHttpValue(); 167 | } 168 | 169 | /** 170 | * Apply the callback if the given "condition" is (or resolves to) true. 171 | * 172 | * @param (callable($this): bool)|bool $condition 173 | * @param callable($this): (self|null) $onSuccess 174 | * @param ?callable($this): (self|null) $onFail 175 | */ 176 | public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): self 177 | { 178 | if (!is_bool($condition)) { 179 | $condition = $condition($this); 180 | } 181 | 182 | return match (true) { 183 | $condition => $onSuccess($this), 184 | null !== $onFail => $onFail($this), 185 | default => $this, 186 | } ?? $this; 187 | } 188 | 189 | /** 190 | * @return array{0:list, 1:Parameters} 191 | */ 192 | public function toPair(): array 193 | { 194 | return [$this->members, $this->parameters]; 195 | } 196 | 197 | public function getIterator(): Iterator 198 | { 199 | yield from $this->members; 200 | } 201 | 202 | public function count(): int 203 | { 204 | return count($this->members); 205 | } 206 | 207 | public function isEmpty(): bool 208 | { 209 | return !$this->isNotEmpty(); 210 | } 211 | 212 | public function isNotEmpty(): bool 213 | { 214 | return [] !== $this->members; 215 | } 216 | 217 | /** 218 | * @return array 219 | */ 220 | public function indices(): array 221 | { 222 | return array_keys($this->members); 223 | } 224 | 225 | public function hasIndices(int ...$indices): bool 226 | { 227 | $max = count($this->members); 228 | foreach ($indices as $offset) { 229 | if (null === $this->filterIndex($offset, $max)) { 230 | return false; 231 | } 232 | } 233 | 234 | return [] !== $indices; 235 | } 236 | 237 | private function filterIndex(int $index, ?int $max = null): ?int 238 | { 239 | $max ??= count($this->members); 240 | 241 | return match (true) { 242 | [] === $this->members, 243 | 0 > $max + $index, 244 | 0 > $max - $index - 1 => null, 245 | 0 > $index => $max + $index, 246 | default => $index, 247 | }; 248 | } 249 | 250 | /** 251 | * @param ?callable(Item): (bool|string) $validate 252 | * 253 | * @throws SyntaxError|Violation|StructuredFieldError 254 | */ 255 | public function getByIndex(int $index, ?callable $validate = null): Item 256 | { 257 | $value = $this->members[$this->filterIndex($index) ?? throw InvalidOffset::dueToIndexNotFound($index)]; 258 | if (null === $validate) { 259 | return $value; 260 | } 261 | 262 | if (true === ($exceptionMessage = $validate($value))) { 263 | return $value; 264 | } 265 | 266 | if (!is_string($exceptionMessage) || '' === trim($exceptionMessage)) { 267 | $exceptionMessage = "The item at '{index}' whose value is '{value}' failed validation."; 268 | } 269 | 270 | throw new Violation(strtr($exceptionMessage, ['{index}' => $index, '{value}' => $value->toHttpValue()])); 271 | } 272 | 273 | public function first(): ?Item 274 | { 275 | return $this->members[0] ?? null; 276 | } 277 | 278 | public function last(): ?Item 279 | { 280 | return $this->members[$this->filterIndex(-1)] ?? null; 281 | } 282 | 283 | public function withParameters(StructuredFieldProvider|Parameters $parameters): static 284 | { 285 | if ($parameters instanceof StructuredFieldProvider) { 286 | $parameters = $parameters->toStructuredField(); 287 | if (!$parameters instanceof Parameters) { 288 | throw new InvalidArgument('The '.StructuredFieldProvider::class.' must provide a '.Parameters::class.'; '.$parameters::class.' given.'); 289 | } 290 | } 291 | 292 | return $this->parameters->equals($parameters) ? $this : new self($this->members, $parameters); 293 | } 294 | 295 | /** 296 | * Inserts members at the beginning of the list. 297 | */ 298 | public function unshift( 299 | StructuredFieldProvider|OuterList|Dictionary|InnerList|Parameters|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool ...$members 300 | ): self { 301 | $membersToAdd = array_reduce( 302 | $members, 303 | function (array $carry, $member) { 304 | if ($member instanceof StructuredFieldProvider) { 305 | $member = $member->toStructuredField(); 306 | } 307 | 308 | return [...$carry, ...$member instanceof InnerList ? [...$member] : [$member]]; 309 | }, 310 | [] 311 | ); 312 | 313 | return match (true) { 314 | [] === $membersToAdd => $this, 315 | default => new self([...array_values($membersToAdd), ...$this->members], $this->parameters), 316 | }; 317 | } 318 | 319 | /** 320 | * Inserts members at the end of the list. 321 | */ 322 | public function push( 323 | StructuredFieldProvider|OuterList|Dictionary|InnerList|Parameters|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool ...$members 324 | ): self { 325 | $membersToAdd = array_reduce( 326 | $members, 327 | function (array $carry, $member) { 328 | if ($member instanceof StructuredFieldProvider) { 329 | $member = $member->toStructuredField(); 330 | } 331 | 332 | return [...$carry, ...$member instanceof InnerList ? [...$member] : [$member]]; 333 | }, 334 | [] 335 | ); 336 | 337 | return match (true) { 338 | [] === $membersToAdd => $this, 339 | default => new self([...$this->members, ...array_values($membersToAdd)], $this->parameters), 340 | }; 341 | } 342 | 343 | /** 344 | * Inserts members starting at the given index. 345 | * 346 | * @throws InvalidOffset If the index does not exist 347 | */ 348 | public function insert( 349 | int $index, 350 | StructuredFieldProvider|OuterList|Dictionary|InnerList|Parameters|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool ...$members 351 | ): self { 352 | $offset = $this->filterIndex($index) ?? throw InvalidOffset::dueToIndexNotFound($index); 353 | 354 | return match (true) { 355 | 0 === $offset => $this->unshift(...$members), 356 | count($this->members) === $offset => $this->push(...$members), 357 | [] === $members => $this, 358 | default => (function (array $newMembers) use ($offset, $members) { 359 | array_splice($newMembers, $offset, 0, $members); 360 | 361 | return new self($newMembers, $this->parameters); 362 | })($this->members), 363 | }; 364 | } 365 | 366 | public function replace( 367 | int $index, 368 | StructuredFieldProvider|OuterList|Dictionary|InnerList|Parameters|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool $member 369 | ): self { 370 | $offset = $this->filterIndex($index) ?? throw InvalidOffset::dueToIndexNotFound($index); 371 | $member = Member::item($member); 372 | 373 | return match (true) { 374 | $member->equals($this->members[$offset]) => $this, 375 | default => new self(array_replace($this->members, [$offset => $member]), $this->parameters), 376 | }; 377 | } 378 | 379 | public function removeByIndices(int ...$indices): self 380 | { 381 | $max = count($this->members); 382 | $indices = array_filter( 383 | array_map(fn (int $index): ?int => $this->filterIndex($index, $max), $indices), 384 | fn (?int $index): bool => null !== $index 385 | ); 386 | 387 | return match (true) { 388 | [] === $indices => $this, 389 | count($indices) === $max => self::new(), 390 | default => new self(array_filter( 391 | $this->members, 392 | fn (int $offset): bool => !in_array($offset, $indices, true), 393 | ARRAY_FILTER_USE_KEY 394 | ), $this->parameters), 395 | }; 396 | } 397 | 398 | /** 399 | * @param int $offset 400 | */ 401 | public function offsetExists(mixed $offset): bool 402 | { 403 | return $this->hasIndices($offset); 404 | } 405 | 406 | /** 407 | * @param int $offset 408 | */ 409 | public function offsetGet(mixed $offset): Item 410 | { 411 | return $this->getByIndex($offset); 412 | } 413 | 414 | public function offsetUnset(mixed $offset): void 415 | { 416 | throw new ForbiddenOperation(self::class.' instance can not be updated using '.ArrayAccess::class.' methods.'); 417 | } 418 | 419 | public function offsetSet(mixed $offset, mixed $value): void 420 | { 421 | throw new ForbiddenOperation(self::class.' instance can not be updated using '.ArrayAccess::class.' methods.'); 422 | } 423 | 424 | /** 425 | * @param callable(Item, int): TMap $callback 426 | * 427 | * @template TMap 428 | * 429 | * @return Iterator 430 | */ 431 | public function map(callable $callback): Iterator 432 | { 433 | foreach ($this->members as $offset => $member) { 434 | yield ($callback)($member, $offset); 435 | } 436 | } 437 | 438 | /** 439 | * @param callable(TInitial|null, Item, int=): TInitial $callback 440 | * @param TInitial|null $initial 441 | * 442 | * @template TInitial 443 | * 444 | * @return TInitial|null 445 | */ 446 | public function reduce(callable $callback, mixed $initial = null): mixed 447 | { 448 | foreach ($this->members as $offset => $member) { 449 | $initial = $callback($initial, $member, $offset); 450 | } 451 | 452 | return $initial; 453 | } 454 | 455 | /** 456 | * @param callable(Item, int): bool $callback 457 | */ 458 | public function filter(callable $callback): self 459 | { 460 | $members = array_filter($this->members, $callback, ARRAY_FILTER_USE_BOTH); 461 | if ($members === $this->members) { 462 | return $this; 463 | } 464 | 465 | return new self($members, $this->parameters); 466 | } 467 | 468 | /** 469 | * @param callable(Item, Item): int $callback 470 | */ 471 | public function sort(callable $callback): self 472 | { 473 | $members = $this->members; 474 | uasort($members, $callback); 475 | 476 | return new self($members, $this->parameters); 477 | } 478 | } 479 | -------------------------------------------------------------------------------- /src/InvalidArgument.php: -------------------------------------------------------------------------------- 1 | }|array $pair) try to create a new instance from a Pair 34 | * @method static ?Item tryFromRfc9651(Stringable|string $httpValue) try to create a new instance from a string using RFC9651 35 | * @method static ?Item tryFromRfc8941(Stringable|string $httpValue) try to create a new instance from a string using RFC8941 36 | * @method static ?Item tryFromHttpValue(Stringable|string $httpValue) try to create a new instance from a string 37 | * @method static ?Item tryFromAssociative(Bytes|Token|DisplayString|DateTimeInterface|string|int|float|bool $value, StructuredFieldProvider|Parameters|iterable $parameters) try to create a new instance from a value and a parameters as associative construct 38 | * @method static ?Item tryNew(mixed $value) try to create a new bare instance from a value 39 | * @method static ?Item tryFromEncodedBytes(Stringable|string $value) try to create a new instance from an encoded byte sequence 40 | * @method static ?Item tryFromDecodedBytes(Stringable|string $value) try to create a new instance from a decoded byte sequence 41 | * @method static ?Item tryFromEncodedDisplayString(Stringable|string $value) try to create a new instance from an encoded display string 42 | * @method static ?Item tryFromDecodedDisplayString(Stringable|string $value) try to create a new instance from a decoded display string 43 | * @method static ?Item tryFromToken(Stringable|string $value) try to create a new instance from a token string 44 | * @method static ?Item tryFromTimestamp(int $timestamp) try to create a new instance from a timestamp 45 | * @method static ?Item tryFromDateFormat(string $format, string $datetime) try to create a new instance from a date format 46 | * @method static ?Item tryFromDateString(string $datetime, DateTimeZone|string|null $timezone = null) try to create a new instance from a date string 47 | * @method static ?Item tryFromDate(DateTimeInterface $datetime) try to create a new instance from a DateTimeInterface object 48 | * @method static ?Item tryFromDecimal(int|float $value) try to create a new instance from a float 49 | * @method static ?Item tryFromInteger(int|float $value) try to create a new instance from an integer 50 | */ 51 | final class Item 52 | { 53 | use ParameterAccess; 54 | 55 | private readonly Token|Bytes|DisplayString|DateTimeImmutable|int|float|string|bool $value; 56 | private readonly Parameters $parameters; 57 | private readonly Type $type; 58 | 59 | private function __construct(Token|Bytes|DisplayString|DateTimeInterface|int|float|string|bool $value, ?Parameters $parameters = null) 60 | { 61 | if ($value instanceof DateTimeInterface && !$value instanceof DateTimeImmutable) { 62 | $value = DateTimeImmutable::createFromInterface($value); 63 | } 64 | 65 | $this->value = $value; 66 | $this->parameters = $parameters ?? Parameters::new(); 67 | $this->type = Type::fromVariable($value); 68 | } 69 | 70 | /** 71 | * @throws BadMethodCallException 72 | */ 73 | public static function __callStatic(string $name, array $arguments): ?self /* @phpstan-ignore-line */ 74 | { 75 | if (!str_starts_with($name, 'try')) { 76 | throw new BadMethodCallException('The method "'.self::class.'::'.$name.'" does not exist.'); 77 | } 78 | 79 | $namedConstructor = lcfirst(substr($name, strlen('try'))); 80 | if (!method_exists(self::class, $namedConstructor)) { 81 | throw new BadMethodCallException('The method "'.self::class.'::'.$name.'" does not exist.'); 82 | } 83 | 84 | $method = new ReflectionMethod(self::class, $namedConstructor); 85 | if (!$method->isPublic() || !$method->isStatic()) { 86 | throw new BadMethodCallException('The method "'.self::class.'::'.$name.'" can not be accessed directly.'); 87 | } 88 | 89 | try { 90 | return self::$namedConstructor(...$arguments); /* @phpstan-ignore-line */ 91 | } catch (Throwable) { /* @phpstan-ignore-line */ 92 | return null; 93 | } 94 | } 95 | 96 | public static function fromRfc9651(Stringable|string $httpValue): self 97 | { 98 | return self::fromHttpValue($httpValue, Ietf::Rfc9651); 99 | } 100 | 101 | public static function fromRfc8941(Stringable|string $httpValue): self 102 | { 103 | return self::fromHttpValue($httpValue, Ietf::Rfc8941); 104 | } 105 | 106 | /** 107 | * Returns a new instance from an HTTP Header or Trailer value string 108 | * in compliance with a published RFC. 109 | * 110 | * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-3.3 111 | * 112 | * @throws SyntaxError|Exception If the HTTP value can not be parsed 113 | */ 114 | public static function fromHttpValue(Stringable|string $httpValue, Ietf $rfc = Ietf::Rfc9651): self 115 | { 116 | return self::fromPair((new Parser($rfc))->parseItem($httpValue)); 117 | } 118 | 119 | /** 120 | * Returns a new instance from a value type and an iterable of key-value parameters. 121 | * 122 | * @param StructuredFieldProvider|Parameters|iterable $parameters 123 | * 124 | * @throws SyntaxError If the value or the parameters are not valid 125 | */ 126 | public static function fromAssociative( 127 | Bytes|Token|DisplayString|DateTimeInterface|string|int|float|bool $value, 128 | StructuredFieldProvider|Parameters|iterable $parameters 129 | ): self { 130 | if ($parameters instanceof StructuredFieldProvider) { 131 | $parameters = $parameters->toStructuredField(); 132 | if ($parameters instanceof Parameters) { 133 | return new self($value, $parameters); 134 | } 135 | 136 | throw new InvalidArgument('The '.StructuredFieldProvider::class.' must provide a '.Parameters::class.'; '.$parameters::class.' given.'); 137 | } 138 | 139 | if (!$parameters instanceof Parameters) { 140 | return new self($value, Parameters::fromAssociative($parameters)); 141 | } 142 | 143 | return new self($value, $parameters); 144 | } 145 | 146 | /** 147 | * @param array{0: SfItemInput, 1?: Parameters|iterable}|array $pair 148 | * 149 | * @throws SyntaxError If the pair or its content is not valid. 150 | */ 151 | public static function fromPair(array $pair): self 152 | { 153 | $nbElements = count($pair); 154 | if (!in_array($nbElements, [1, 2], true) || !array_is_list($pair)) { 155 | throw new SyntaxError('The pair must be represented by an non-empty array as a list containing exactly 1 or 2 members.'); 156 | } 157 | 158 | if (1 === $nbElements) { 159 | return new self($pair[0]); 160 | } 161 | 162 | if ($pair[1] instanceof StructuredFieldProvider) { 163 | $pair[1] = $pair[1]->toStructuredField(); 164 | if ($pair[1] instanceof Parameters) { 165 | return new self($pair[0], Parameters::fromPairs($pair[1])); 166 | } 167 | 168 | throw new InvalidArgument('The '.StructuredFieldProvider::class.' must provide a '.Parameters::class.'; '.$pair[1]::class.' given.'); 169 | } 170 | 171 | if (!$pair[1] instanceof Parameters) { 172 | return new self($pair[0], Parameters::fromPairs($pair[1])); 173 | } 174 | 175 | return new self($pair[0], $pair[1]); 176 | } 177 | 178 | /** 179 | * Returns a new bare instance from value. 180 | * 181 | * @param SfItemPair|SfItemInput $value 182 | * 183 | * @throws SyntaxError|TypeError If the value is not valid. 184 | */ 185 | public static function new(mixed $value): self 186 | { 187 | if (is_array($value)) { 188 | return self::fromPair($value); 189 | } 190 | 191 | return new self($value); /* @phpstan-ignore-line */ 192 | } 193 | 194 | /** 195 | * Returns a new instance from a string. 196 | * 197 | * @throws SyntaxError if the string is invalid 198 | */ 199 | public static function fromString(Stringable|string $value): self 200 | { 201 | return new self((string)$value); 202 | } 203 | 204 | /** 205 | * Returns a new instance from an encoded byte sequence and an iterable of key-value parameters. 206 | * 207 | * @throws SyntaxError if the sequence is invalid 208 | */ 209 | public static function fromEncodedBytes(Stringable|string $value): self 210 | { 211 | return new self(Bytes::fromEncoded($value)); 212 | } 213 | 214 | /** 215 | * Returns a new instance from a decoded byte sequence and an iterable of key-value parameters. 216 | * 217 | * @throws SyntaxError if the sequence is invalid 218 | */ 219 | public static function fromDecodedBytes(Stringable|string $value): self 220 | { 221 | return new self(Bytes::fromDecoded($value)); 222 | } 223 | 224 | /** 225 | * Returns a new instance from an encoded byte sequence and an iterable of key-value parameters. 226 | * 227 | * @throws SyntaxError if the sequence is invalid 228 | */ 229 | public static function fromEncodedDisplayString(Stringable|string $value): self 230 | { 231 | return new self(DisplayString::fromEncoded($value)); 232 | } 233 | 234 | /** 235 | * Returns a new instance from a decoded byte sequence and an iterable of key-value parameters. 236 | * 237 | * @throws SyntaxError if the sequence is invalid 238 | */ 239 | public static function fromDecodedDisplayString(Stringable|string $value): self 240 | { 241 | return new self(DisplayString::fromDecoded($value)); 242 | } 243 | 244 | /** 245 | * Returns a new instance from a Token and an iterable of key-value parameters. 246 | * 247 | * @throws SyntaxError if the token is invalid 248 | */ 249 | public static function fromToken(Stringable|string $value): self 250 | { 251 | return new self(Token::fromString($value)); 252 | } 253 | 254 | /** 255 | * Returns a new instance from a timestamp and an iterable of key-value parameters. 256 | * 257 | * @throws SyntaxError if the timestamp value is not supported 258 | */ 259 | public static function fromTimestamp(int $timestamp): self 260 | { 261 | return new self((new DateTimeImmutable())->setTimestamp($timestamp)); 262 | } 263 | 264 | /** 265 | * Returns a new instance from a date format its date string representation and an iterable of key-value parameters. 266 | * 267 | * @throws SyntaxError if the format is invalid 268 | */ 269 | public static function fromDateFormat(string $format, string $datetime): self 270 | { 271 | try { 272 | $value = DateTimeImmutable::createFromFormat($format, $datetime); 273 | } catch (Exception $exception) { 274 | throw new SyntaxError('The date notation `'.$datetime.'` is incompatible with the date format `'.$format.'`.', 0, $exception); 275 | } 276 | 277 | if (!$value instanceof DateTimeImmutable) { 278 | throw new SyntaxError('The date notation `'.$datetime.'` is incompatible with the date format `'.$format.'`.'); 279 | } 280 | 281 | return new self($value); 282 | } 283 | 284 | /** 285 | * Returns a new instance from a string parsable by DateTimeImmutable constructor, an optional timezone and an iterable of key-value parameters. 286 | * 287 | * @throws SyntaxError if the format is invalid 288 | */ 289 | public static function fromDateString(string $datetime, DateTimeZone|string|null $timezone = null): self 290 | { 291 | $timezone ??= date_default_timezone_get(); 292 | if (!$timezone instanceof DateTimeZone) { 293 | try { 294 | $timezone = new DateTimeZone($timezone); 295 | } catch (Throwable $exception) { 296 | throw new SyntaxError('The timezone could not be instantiated.', 0, $exception); 297 | } 298 | } 299 | 300 | try { 301 | return new self(new DateTimeImmutable($datetime, $timezone)); 302 | } catch (Throwable $exception) { 303 | throw new SyntaxError('Unable to create a '.DateTimeImmutable::class.' instance with the date notation `'.$datetime.'.`', 0, $exception); 304 | } 305 | } 306 | 307 | /** 308 | * Returns a new instance from a DateTineInterface implementing object. 309 | * 310 | * @throws SyntaxError if the format is invalid 311 | */ 312 | public static function fromDate(DateTimeInterface $datetime): self 313 | { 314 | return new self($datetime); 315 | } 316 | 317 | /** 318 | * Returns a new instance from a float value. 319 | * 320 | * @throws SyntaxError if the format is invalid 321 | */ 322 | public static function fromDecimal(int|float $value): self 323 | { 324 | return new self((float)$value); 325 | } 326 | 327 | /** 328 | * Returns a new instance from an integer value. 329 | * 330 | * @throws SyntaxError if the format is invalid 331 | */ 332 | public static function fromInteger(int|float $value): self 333 | { 334 | return new self((int)$value); 335 | } 336 | 337 | /** 338 | * Returns a new instance for the boolean true type. 339 | */ 340 | public static function true(): self 341 | { 342 | return new self(true); 343 | } 344 | 345 | /** 346 | * Returns a new instance for the boolean false type. 347 | */ 348 | public static function false(): self 349 | { 350 | return new self(false); 351 | } 352 | 353 | /** 354 | * Returns the underlying value. 355 | * If a validation rule is provided, an exception will be thrown 356 | * if the validation rules does not return true. 357 | * 358 | * if the validation returns false then a default validation message will be return; otherwise the submitted message string will be returned as is. 359 | * 360 | * @param ?callable(SfType): (string|bool) $validate 361 | * 362 | * @throws Violation 363 | */ 364 | public function value(?callable $validate = null): Bytes|Token|DisplayString|DateTimeImmutable|string|int|float|bool 365 | { 366 | if (null === $validate) { 367 | return $this->value; 368 | } 369 | 370 | $exceptionMessage = $validate($this->value); 371 | if (true === $exceptionMessage) { 372 | return $this->value; 373 | } 374 | 375 | if (!is_string($exceptionMessage) || '' === trim($exceptionMessage)) { 376 | $exceptionMessage = "The item value '{value}' failed validation."; 377 | } 378 | 379 | throw new Violation(strtr($exceptionMessage, ['{value}' => $this->serialize()])); 380 | } 381 | 382 | public function type(): Type 383 | { 384 | return $this->type; 385 | } 386 | 387 | /** 388 | * Serialize the Item value according to RFC8941. 389 | * 390 | * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-4.1 391 | */ 392 | public function toHttpValue(Ietf $rfc = Ietf::Rfc9651): string 393 | { 394 | return $this->serialize($rfc).$this->parameters->toHttpValue($rfc); 395 | } 396 | 397 | /** 398 | * Serialize the Item value according to RFC8941. 399 | * 400 | * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-4.1 401 | */ 402 | private function serialize(Ietf $rfc = Ietf::Rfc9651): string 403 | { 404 | return match (true) { 405 | !$rfc->supports($this->type) => throw MissingFeature::dueToLackOfSupport($this->type, $rfc), 406 | $this->value instanceof DateTimeImmutable => '@'.$this->value->getTimestamp(), 407 | $this->value instanceof Token => $this->value->toString(), 408 | $this->value instanceof Bytes => ':'.$this->value->encoded().':', 409 | $this->value instanceof DisplayString => '%"'.$this->value->encoded().'"', 410 | is_int($this->value) => (string) $this->value, 411 | is_float($this->value) => (string) json_encode(round($this->value, 3, PHP_ROUND_HALF_EVEN), JSON_PRESERVE_ZERO_FRACTION), 412 | $this->value, 413 | false === $this->value => '?'.($this->value ? '1' : '0'), 414 | default => '"'.preg_replace('/(["\\\])/', '\\\$1', $this->value).'"', 415 | }; 416 | } 417 | 418 | public function toRfc9651(): string 419 | { 420 | return $this->toHttpValue(Ietf::Rfc9651); 421 | } 422 | 423 | public function toRfc8941(): string 424 | { 425 | return $this->toHttpValue(Ietf::Rfc8941); 426 | } 427 | 428 | public function __toString(): string 429 | { 430 | return $this->toHttpValue(); 431 | } 432 | 433 | /** 434 | * @return array{0:SfItemInput, 1:Parameters} 435 | */ 436 | public function toPair(): array 437 | { 438 | return [$this->value, $this->parameters]; 439 | } 440 | 441 | public function equals(mixed $other): bool 442 | { 443 | return $other instanceof self && $other->toHttpValue() === $this->toHttpValue(); 444 | } 445 | 446 | /** 447 | * Apply the callback if the given "condition" is (or resolves to) true. 448 | * 449 | * @param (callable($this): bool)|bool $condition 450 | * @param callable($this): (self|null) $onSuccess 451 | * @param ?callable($this): (self|null) $onFail 452 | */ 453 | public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): self 454 | { 455 | if (!is_bool($condition)) { 456 | $condition = $condition($this); 457 | } 458 | 459 | return match (true) { 460 | $condition => $onSuccess($this), 461 | null !== $onFail => $onFail($this), 462 | default => $this, 463 | } ?? $this; 464 | } 465 | 466 | /** 467 | * Returns a new instance with the newly associated value. 468 | * 469 | * This method MUST retain the state of the current instance, and return 470 | * an instance that contains the specified value change. 471 | * 472 | * @throws SyntaxError If the value is invalid or not supported 473 | */ 474 | public function withValue(DateTimeInterface|Bytes|Token|DisplayString|string|int|float|bool $value): self 475 | { 476 | $isEqual = match (true) { 477 | $this->value instanceof Bytes, 478 | $this->value instanceof Token, 479 | $this->value instanceof DisplayString => $this->value->equals($value), 480 | $this->value instanceof DateTimeInterface && $value instanceof DateTimeInterface => $value->getTimestamp() === $this->value->getTimestamp(), 481 | default => $value === $this->value, 482 | }; 483 | 484 | if ($isEqual) { 485 | return $this; 486 | } 487 | 488 | return new self($value, $this->parameters); 489 | } 490 | 491 | public function withParameters(StructuredFieldProvider|Parameters $parameters): static 492 | { 493 | if ($parameters instanceof StructuredFieldProvider) { 494 | $parameters = $parameters->toStructuredField(); 495 | if (!$parameters instanceof Parameters) { 496 | throw new InvalidArgument('The '.StructuredFieldProvider::class.' must provide a '.Parameters::class.'; '.$parameters::class.' given.'); 497 | } 498 | } 499 | 500 | return $this->parameters->equals($parameters) ? $this : new self($this->value, $parameters); 501 | } 502 | } 503 | -------------------------------------------------------------------------------- /src/Key.php: -------------------------------------------------------------------------------- 1 | value !== $key) { 27 | throw new SyntaxError('No valid http value key could be extracted from "'.$httpValue.'".'); 28 | } 29 | 30 | return $instance; 31 | } 32 | 33 | public static function tryFrom(Stringable|string|int $httpValue): ?self 34 | { 35 | try { 36 | return self::from($httpValue); 37 | } catch (SyntaxError $e) { 38 | return null; 39 | } 40 | } 41 | 42 | /** 43 | * @throws SyntaxError If the string does not start with a valid HTTP value field key 44 | */ 45 | public static function fromStringBeginning(string $httpValue): self 46 | { 47 | if (1 !== preg_match('/^(?[a-z*][a-z\d.*_-]*)/', $httpValue, $found)) { 48 | throw new SyntaxError('No valid http value key could be extracted from "'.$httpValue.'".'); 49 | } 50 | 51 | return new self($found['key']); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Member.php: -------------------------------------------------------------------------------- 1 | toStructuredField(); 30 | if ($value instanceof Item || $value instanceof InnerList) { 31 | return $value; 32 | } 33 | 34 | throw new InvalidArgument('The '.StructuredFieldProvider::class.' must provide a '.Item::class.' or an '.InnerList::class.'; '.$value::class.' given.'); 35 | } 36 | 37 | return match (true) { 38 | $value instanceof InnerList, 39 | $value instanceof Item => $value, 40 | is_iterable($value) => InnerList::new(...$value), 41 | default => Item::new($value), 42 | }; 43 | } 44 | 45 | public static function innerListOrItemFromPair(mixed $value): InnerList|Item 46 | { 47 | if ($value instanceof StructuredFieldProvider) { 48 | $value = $value->toStructuredField(); 49 | if ($value instanceof Item || $value instanceof InnerList) { 50 | return $value; 51 | } 52 | 53 | throw new InvalidArgument('The '.StructuredFieldProvider::class.' must provide a '.Item::class.' or an '.InnerList::class.'; '.$value::class.' given.'); 54 | } 55 | 56 | if ($value instanceof InnerList || $value instanceof Item) { 57 | return $value; 58 | } 59 | 60 | if (!is_array($value)) { 61 | if (is_iterable($value)) { 62 | throw new SyntaxError('The value must be an Item value not an iterable.'); 63 | } 64 | 65 | return Item::new($value); /* @phpstan-ignore-line */ 66 | } 67 | 68 | if (!array_is_list($value)) { 69 | throw new SyntaxError('The pair must be represented by an array as a list.'); 70 | } 71 | 72 | if ([] === $value) { 73 | return InnerList::new(); 74 | } 75 | 76 | if (!in_array(count($value), [1, 2], true)) { 77 | throw new SyntaxError('The pair first member represents its value; the second member is its associated parameters.'); 78 | } 79 | 80 | return is_iterable($value[0]) ? InnerList::fromPair($value) : Item::fromPair($value); 81 | } 82 | 83 | /** 84 | * @param SfItemInput|SfItemPair $value 85 | */ 86 | public static function item(mixed $value): Item 87 | { 88 | if ($value instanceof StructuredFieldProvider) { 89 | $value = $value->toStructuredField(); 90 | if (!$value instanceof Item) { 91 | throw new InvalidArgument('The '.StructuredFieldProvider::class.' must provide a '.Item::class.'; '.$value::class.' given.'); 92 | } 93 | 94 | return $value; 95 | } 96 | 97 | if ($value instanceof Item) { 98 | return $value; 99 | } 100 | 101 | return Item::new($value); 102 | } 103 | 104 | /** 105 | * @param SfItemInput|SfItemPair $value 106 | */ 107 | public static function bareItem(mixed $value): Item 108 | { 109 | $bareItem = self::item($value); 110 | if ($bareItem->parameters()->isNotEmpty()) { 111 | throw new InvalidArgument('The "'.$bareItem::class.'" instance is not a Bare Item.'); 112 | } 113 | 114 | return $bareItem; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/MissingFeature.php: -------------------------------------------------------------------------------- 1 | value.'\' type is not handled by '.strtoupper($rfc->name)); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/OuterList.php: -------------------------------------------------------------------------------- 1 | 37 | * @implements IteratorAggregate 38 | */ 39 | final class OuterList implements ArrayAccess, Countable, IteratorAggregate 40 | { 41 | /** @var list */ 42 | private readonly array $members; 43 | 44 | /** 45 | * @param SfMemberInput ...$members 46 | */ 47 | private function __construct( 48 | iterable|StructuredFieldProvider|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool ...$members 49 | ) { 50 | $this->members = array_map(Member::innerListOrItem(...), array_values([...$members])); 51 | } 52 | 53 | /** 54 | * Returns an instance from an HTTP textual representation. 55 | * 56 | * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-3.1 57 | * 58 | * @throws SyntaxError|Exception 59 | */ 60 | public static function fromHttpValue(Stringable|string $httpValue, Ietf $rfc = Ietf::Rfc9651): self 61 | { 62 | return self::fromPairs((new Parser($rfc))->parseList($httpValue)); /* @phpstan-ignore-line */ 63 | } 64 | 65 | /** 66 | * @param StructuredFieldProvider|iterable $pairs 67 | */ 68 | public static function fromPairs(StructuredFieldProvider|iterable $pairs): self 69 | { 70 | if ($pairs instanceof StructuredFieldProvider) { 71 | $pairs = $pairs->toStructuredField(); 72 | } 73 | 74 | if (!is_iterable($pairs)) { 75 | throw new InvalidArgument('The "'.$pairs::class.'" instance can not be used for creating a .'.self::class.' structured field.'); 76 | } 77 | 78 | return match (true) { 79 | $pairs instanceof OuterList, 80 | $pairs instanceof InnerList => new self($pairs), 81 | default => new self(...(function (iterable $pairs) { 82 | foreach ($pairs as $member) { 83 | yield Member::innerListOrItemFromPair($member); 84 | } 85 | })($pairs)), 86 | }; 87 | } 88 | 89 | /** 90 | * @param StructuredFieldProvider|SfInnerListPair|SfItemPair|SfMemberInput ...$members 91 | */ 92 | public static function new(iterable|StructuredFieldProvider|InnerList|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool ...$members): self 93 | { 94 | return self::fromPairs($members); /* @phpstan-ignore-line*/ 95 | } 96 | 97 | public static function fromRfc9651(Stringable|string $httpValue): self 98 | { 99 | return self::fromHttpValue($httpValue, Ietf::Rfc9651); 100 | } 101 | 102 | public static function fromRfc8941(Stringable|string $httpValue): self 103 | { 104 | return self::fromHttpValue($httpValue, Ietf::Rfc8941); 105 | } 106 | 107 | public function toHttpValue(Ietf $rfc = Ietf::Rfc9651): string 108 | { 109 | return implode(', ', array_map(fn (Item|InnerList $member): string => $member->toHttpValue($rfc), $this->members)); 110 | } 111 | 112 | public function toRfc9651(): string 113 | { 114 | return $this->toHttpValue(Ietf::Rfc9651); 115 | } 116 | 117 | public function toRfc8941(): string 118 | { 119 | return $this->toHttpValue(Ietf::Rfc8941); 120 | } 121 | 122 | public function __toString(): string 123 | { 124 | return $this->toHttpValue(); 125 | } 126 | 127 | public function equals(mixed $other): bool 128 | { 129 | return $other instanceof self && $other->toHttpValue() === $this->toHttpValue(); 130 | } 131 | 132 | /** 133 | * Apply the callback if the given "condition" is (or resolves to) true. 134 | * 135 | * @param (callable($this): bool)|bool $condition 136 | * @param callable($this): (self|null) $onSuccess 137 | * @param ?callable($this): (self|null) $onFail 138 | */ 139 | public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): self 140 | { 141 | if (!is_bool($condition)) { 142 | $condition = $condition($this); 143 | } 144 | 145 | return match (true) { 146 | $condition => $onSuccess($this), 147 | null !== $onFail => $onFail($this), 148 | default => $this, 149 | } ?? $this; 150 | } 151 | 152 | public function getIterator(): Iterator 153 | { 154 | yield from $this->members; 155 | } 156 | 157 | public function count(): int 158 | { 159 | return count($this->members); 160 | } 161 | 162 | public function isEmpty(): bool 163 | { 164 | return !$this->isNotEmpty(); 165 | } 166 | 167 | public function isNotEmpty(): bool 168 | { 169 | return [] !== $this->members; 170 | } 171 | 172 | /** 173 | * @return array 174 | */ 175 | public function indices(): array 176 | { 177 | return array_keys($this->members); 178 | } 179 | 180 | public function hasIndices(int ...$indices): bool 181 | { 182 | $max = count($this->members); 183 | foreach ($indices as $index) { 184 | if (null === $this->filterIndex($index, $max)) { 185 | return false; 186 | } 187 | } 188 | 189 | return [] !== $indices; 190 | } 191 | 192 | private function filterIndex(int $index, ?int $max = null): ?int 193 | { 194 | $max ??= count($this->members); 195 | 196 | return match (true) { 197 | [] === $this->members, 198 | 0 > $max + $index, 199 | 0 > $max - $index - 1 => null, 200 | 0 > $index => $max + $index, 201 | default => $index, 202 | }; 203 | } 204 | 205 | /** 206 | * @param ?callable(InnerList|Item): (bool|string) $validate 207 | * 208 | * @throws SyntaxError|Violation|StructuredFieldError 209 | */ 210 | public function getByIndex(int $index, ?callable $validate = null): InnerList|Item 211 | { 212 | $value = $this->members[$this->filterIndex($index) ?? throw InvalidOffset::dueToIndexNotFound($index)]; 213 | if (null === $validate) { 214 | return $value; 215 | } 216 | 217 | if (true === ($exceptionMessage = $validate($value))) { 218 | return $value; 219 | } 220 | 221 | if (!is_string($exceptionMessage) || '' === trim($exceptionMessage)) { 222 | $exceptionMessage = "The member at position '{index}' whose value is '{value}' failed validation."; 223 | } 224 | 225 | throw new Violation(strtr($exceptionMessage, ['{index}' => $index, '{value}' => $value->toHttpValue()])); 226 | } 227 | 228 | public function first(): InnerList|Item|null 229 | { 230 | return $this->members[0] ?? null; 231 | } 232 | 233 | public function last(): InnerList|Item|null 234 | { 235 | return $this->members[$this->filterIndex(-1)] ?? null; 236 | } 237 | 238 | /** 239 | * Inserts members at the beginning of the list. 240 | * 241 | * @param SfMemberInput ...$members 242 | */ 243 | public function unshift( 244 | StructuredFieldProvider|InnerList|Item|iterable|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool ...$members 245 | ): self { 246 | $membersToAdd = array_reduce( 247 | $members, 248 | function (array $carry, $member) { 249 | if ($member instanceof StructuredFieldProvider) { 250 | $member = $member->toStructuredField(); 251 | } 252 | 253 | return [...$carry, ...$member instanceof InnerList ? [...$member] : [$member]]; 254 | }, 255 | [] 256 | ); 257 | 258 | return match (true) { 259 | [] === $membersToAdd => $this, 260 | default => new self(...array_values($membersToAdd), ...$this->members), 261 | }; 262 | } 263 | 264 | /** 265 | * Inserts members at the end of the list. 266 | * 267 | * @param SfMemberInput ...$members 268 | */ 269 | public function push( 270 | iterable|StructuredFieldProvider|InnerList|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool ...$members 271 | ): self { 272 | $membersToAdd = array_reduce( 273 | $members, 274 | function (array $carry, $member) { 275 | if ($member instanceof StructuredFieldProvider) { 276 | $member = $member->toStructuredField(); 277 | } 278 | 279 | return [...$carry, ...$member instanceof InnerList ? [...$member] : [$member]]; 280 | }, 281 | [] 282 | ); 283 | 284 | return match (true) { 285 | [] === $membersToAdd => $this, 286 | default => new self(...$this->members, ...array_values($membersToAdd)), 287 | }; 288 | } 289 | 290 | /** 291 | * Inserts members starting at the given index. 292 | * 293 | * @param SfMemberInput ...$members 294 | * 295 | * @throws InvalidOffset If the index does not exist 296 | */ 297 | public function insert( 298 | int $index, 299 | iterable|StructuredFieldProvider|InnerList|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool ...$members 300 | ): self { 301 | $offset = $this->filterIndex($index) ?? throw InvalidOffset::dueToIndexNotFound($index); 302 | 303 | return match (true) { 304 | 0 === $offset => $this->unshift(...$members), 305 | count($this->members) === $offset => $this->push(...$members), 306 | [] === $members => $this, 307 | default => (function (array $newMembers) use ($offset, $members) { 308 | array_splice($newMembers, $offset, 0, $members); 309 | 310 | return new self(...$newMembers); 311 | })($this->members), 312 | }; 313 | } 314 | 315 | /** 316 | * @param SfMemberInput $member 317 | */ 318 | public function replace( 319 | int $index, 320 | iterable|StructuredFieldProvider|InnerList|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool $member 321 | ): self { 322 | $offset = $this->filterIndex($index) ?? throw InvalidOffset::dueToIndexNotFound($index); 323 | $member = Member::innerListOrItem($member); 324 | 325 | return match (true) { 326 | $member->equals($this->members[$offset]) => $this, 327 | default => new self(...array_replace($this->members, [$offset => $member])), 328 | }; 329 | } 330 | 331 | public function removeByIndices(int ...$indices): self 332 | { 333 | $max = count($this->members); 334 | $offsets = array_filter( 335 | array_map(fn (int $index): ?int => $this->filterIndex($index, $max), $indices), 336 | fn (?int $index): bool => null !== $index 337 | ); 338 | 339 | return match (true) { 340 | [] === $offsets => $this, 341 | $max === count($offsets) => new self(), 342 | default => new self(...array_filter( 343 | $this->members, 344 | fn (int $index): bool => !in_array($index, $offsets, true), 345 | ARRAY_FILTER_USE_KEY 346 | )), 347 | }; 348 | } 349 | 350 | /** 351 | * @param int $offset 352 | */ 353 | public function offsetExists(mixed $offset): bool 354 | { 355 | return $this->hasIndices($offset); 356 | } 357 | 358 | /** 359 | * @param int $offset 360 | */ 361 | public function offsetGet(mixed $offset): InnerList|Item 362 | { 363 | return $this->getByIndex($offset); 364 | } 365 | 366 | public function offsetUnset(mixed $offset): void 367 | { 368 | throw new ForbiddenOperation(self::class.' instance can not be updated using '.ArrayAccess::class.' methods.'); 369 | } 370 | 371 | public function offsetSet(mixed $offset, mixed $value): void 372 | { 373 | throw new ForbiddenOperation(self::class.' instance can not be updated using '.ArrayAccess::class.' methods.'); 374 | } 375 | 376 | /** 377 | * @param callable(InnerList|Item, int): TMap $callback 378 | * 379 | * @template TMap 380 | * 381 | * @return Iterator 382 | */ 383 | public function map(callable $callback): Iterator 384 | { 385 | foreach ($this->members as $offset => $member) { 386 | yield ($callback)($member, $offset); 387 | } 388 | } 389 | 390 | /** 391 | * @param callable(TInitial|null, InnerList|Item, int): TInitial $callback 392 | * @param TInitial|null $initial 393 | * 394 | * @template TInitial 395 | * 396 | * @return TInitial|null 397 | */ 398 | public function reduce(callable $callback, mixed $initial = null): mixed 399 | { 400 | foreach ($this->members as $offset => $member) { 401 | $initial = $callback($initial, $member, $offset); 402 | } 403 | 404 | return $initial; 405 | } 406 | 407 | /** 408 | * @param callable(InnerList|Item, int): bool $callback 409 | */ 410 | public function filter(callable $callback): self 411 | { 412 | $members = array_filter($this->members, $callback, ARRAY_FILTER_USE_BOTH); 413 | if ($members === $this->members) { 414 | return $this; 415 | } 416 | 417 | return new self(...$members); 418 | } 419 | 420 | /** 421 | * @param callable(InnerList|Item, InnerList|Item): int $callback 422 | */ 423 | public function sort(callable $callback): self 424 | { 425 | $members = $this->members; 426 | uasort($members, $callback); 427 | 428 | return new self(...$members); 429 | } 430 | } 431 | -------------------------------------------------------------------------------- /src/ParameterAccess.php: -------------------------------------------------------------------------------- 1 | parameters; 26 | } 27 | 28 | /** 29 | * Returns the member value or null if no members value exists. 30 | * 31 | * @param ?callable(SfType): (bool|string) $validate 32 | * 33 | * @throws Violation if the validation fails 34 | * 35 | * @return SfType|null 36 | */ 37 | public function parameterByKey( 38 | string $key, 39 | ?callable $validate = null, 40 | bool|string $required = false, 41 | Bytes|Token|DisplayString|DateTimeImmutable|string|int|float|bool|null $default = null 42 | ): Bytes|Token|DisplayString|DateTimeImmutable|string|int|float|bool|null { 43 | return $this->parameters->valueByKey($key, $validate, $required, $default); 44 | } 45 | 46 | /** 47 | * Returns the member value and key as pair or an empty array if no members value exists. 48 | * 49 | * @param ?callable(SfType, string): (bool|string) $validate 50 | * @param array{0:string, 1:SfType}|array{} $default 51 | * 52 | * @throws Violation if the validation fails 53 | * 54 | * @return array{0:string, 1:SfType}|array{} 55 | */ 56 | public function parameterByIndex( 57 | int $index, 58 | ?callable $validate = null, 59 | bool|string $required = false, 60 | array $default = [] 61 | ): array { 62 | return $this->parameters->valueByIndex($index, $validate, $required, $default); 63 | } 64 | 65 | /** 66 | * Returns a new instance with the newly associated parameter instance. 67 | * 68 | * This method MUST retain the state of the current instance, and return 69 | * an instance that contains the specified parameter change. 70 | */ 71 | abstract public function withParameters(Parameters $parameters): static; 72 | 73 | /** 74 | * Adds a member if its key is not present at the of the associated parameter instance or update the instance at the given key. 75 | * 76 | * This method MUST retain the state of the current instance, and return 77 | * an instance that contains the specified parameter change. 78 | * 79 | * @param StructuredFieldProvider|Item|SfType $member 80 | * 81 | * @throws SyntaxError If the string key is not a valid 82 | */ 83 | public function addParameter( 84 | string $key, 85 | StructuredFieldProvider|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool $member 86 | ): static { 87 | return $this->withParameters($this->parameters()->add($key, $member)); 88 | } 89 | 90 | /** 91 | * Adds a member at the start of the associated parameter instance and deletes any previous reference to the key if present. 92 | * 93 | * This method MUST retain the state of the current instance, and return 94 | * an instance that contains the specified parameter change. 95 | * 96 | * @param StructuredFieldProvider|Item|SfType $member 97 | * 98 | * @throws SyntaxError If the string key is not a valid 99 | */ 100 | public function prependParameter( 101 | string $key, 102 | StructuredFieldProvider|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool $member 103 | ): static { 104 | return $this->withParameters($this->parameters()->prepend($key, $member)); 105 | } 106 | 107 | /** 108 | * Adds a member at the end of the associated parameter instance and deletes any previous reference to the key if present. 109 | * 110 | * This method MUST retain the state of the current instance, and return 111 | * an instance that contains the specified parameter change. 112 | * 113 | * @param StructuredFieldProvider|Item|SfType $member 114 | * 115 | * @throws SyntaxError If the string key is not a valid 116 | */ 117 | public function appendParameter( 118 | string $key, 119 | StructuredFieldProvider|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool $member 120 | ): static { 121 | return $this->withParameters($this->parameters()->append($key, $member)); 122 | } 123 | 124 | /** 125 | * Removes all parameters members associated with the list of submitted keys in the associated parameter instance. 126 | * 127 | * This method MUST retain the state of the current instance, and return 128 | * an instance that contains the specified parameter change. 129 | */ 130 | public function withoutAnyParameter(): static 131 | { 132 | return $this->withParameters(Parameters::new()); 133 | } 134 | 135 | /** 136 | * Inserts pair at the end of the member list. 137 | * 138 | * This method MUST retain the state of the current instance, and return 139 | * an instance that contains the specified parameter change. 140 | * 141 | * @param array{0:string, 1:SfItemInput} ...$pairs 142 | */ 143 | public function pushParameters(array ...$pairs): static 144 | { 145 | return $this->withParameters($this->parameters()->push(...$pairs)); 146 | } 147 | 148 | /** 149 | * Inserts pair at the beginning of the member list. 150 | * 151 | * This method MUST retain the state of the current instance, and return 152 | * an instance that contains the specified parameter change. 153 | * 154 | * @param array{0:string, 1:SfItemInput} ...$pairs 155 | */ 156 | public function unshiftParameters(array ...$pairs): static 157 | { 158 | return $this->withParameters($this->parameters()->unshift(...$pairs)); 159 | } 160 | 161 | /** 162 | * Delete member based on their key. 163 | * 164 | * This method MUST retain the state of the current instance, and return 165 | * an instance that contains the specified parameter change. 166 | */ 167 | public function withoutParameterByKeys(string ...$keys): static 168 | { 169 | return $this->withParameters($this->parameters()->removeByKeys(...$keys)); 170 | } 171 | 172 | /** 173 | * Delete member based on their offsets. 174 | * 175 | * This method MUST retain the state of the current instance, and return 176 | * an instance that contains the specified parameter change. 177 | */ 178 | public function withoutParameterByIndices(int ...$indices): static 179 | { 180 | return $this->withParameters($this->parameters()->removeByIndices(...$indices)); 181 | } 182 | 183 | /** 184 | * Inserts members at the specified index. 185 | * 186 | * This method MUST retain the state of the current instance, and return 187 | * an instance that contains the specified parameter change. 188 | * 189 | * @param array{0:string, 1:SfType} ...$pairs 190 | */ 191 | public function insertParameters(int $index, array ...$pairs): static 192 | { 193 | return $this->withParameters($this->parameters()->insert($index, ...$pairs)); 194 | } 195 | 196 | /** 197 | * Replace the member at the specified index. 198 | * 199 | * This method MUST retain the state of the current instance, and return 200 | * an instance that contains the specified parameter change. 201 | * 202 | * @param array{0:string, 1:SfType} $pair 203 | */ 204 | public function replaceParameter(int $index, array $pair): static 205 | { 206 | return $this->withParameters($this->parameters()->replace($index, $pair)); 207 | } 208 | 209 | /** 210 | * Sort the object parameters by value using a callback. 211 | * 212 | * This method MUST retain the state of the current instance, and return 213 | * an instance that contains the specified parameter change. 214 | * 215 | * @param callable(array{0:string, 1:Item}, array{0:string, 1:Item}): int $callback 216 | */ 217 | public function sortParameters(callable $callback): static 218 | { 219 | return $this->withParameters($this->parameters()->sort($callback)); 220 | } 221 | 222 | /** 223 | * Filter the object parameters using a callback. 224 | * 225 | * This method MUST retain the state of the current instance, and return 226 | * an instance that contains the specified parameter change. 227 | * 228 | * @param callable(array{0:string, 1:Item}, int): bool $callback 229 | */ 230 | public function filterParameters(callable $callback): static 231 | { 232 | return $this->withParameters($this->parameters()->filter($callback)); 233 | } 234 | 235 | /** 236 | * Merges multiple instances using iterable pairs. 237 | * 238 | * This method MUST retain the state of the current instance, and return 239 | * an instance that contains the specified changes. 240 | * 241 | * @param StructuredFieldProvider|Parameters|Dictionary|iterable ...$others 242 | */ 243 | public function mergeParametersByPairs(...$others): static 244 | { 245 | return $this->withParameters($this->parameters()->mergePairs(...$others)); 246 | } 247 | 248 | /** 249 | * Merges multiple instances using iterable associative. 250 | * 251 | * This method MUST retain the state of the current instance, and return 252 | * an instance that contains the specified changes. 253 | * 254 | * @param StructuredFieldProvider|Dictionary|Parameters|iterable ...$others 255 | */ 256 | public function mergeParametersByAssociative(...$others): static 257 | { 258 | return $this->withParameters($this->parameters()->mergeAssociative(...$others)); 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /src/Parameters.php: -------------------------------------------------------------------------------- 1 | 36 | * @implements IteratorAggregate 37 | */ 38 | final class Parameters implements ArrayAccess, Countable, IteratorAggregate 39 | { 40 | /** @var array */ 41 | private readonly array $members; 42 | 43 | /** 44 | * @param iterable $members 45 | */ 46 | private function __construct(iterable $members = []) 47 | { 48 | $filteredMembers = []; 49 | foreach ($members as $key => $member) { 50 | $filteredMembers[Key::from($key)->value] = Member::bareItem($member); 51 | } 52 | 53 | $this->members = $filteredMembers; 54 | } 55 | 56 | /** 57 | * Returns a new instance. 58 | */ 59 | public static function new(): self 60 | { 61 | return new self(); 62 | } 63 | 64 | /** 65 | * Returns a new instance from an associative iterable construct. 66 | * 67 | * its keys represent the dictionary entry key 68 | * its values represent the dictionary entry value 69 | * 70 | * @param StructuredFieldProvider|iterable $members 71 | */ 72 | public static function fromAssociative(StructuredFieldProvider|iterable $members): self 73 | { 74 | if ($members instanceof StructuredFieldProvider) { 75 | $structuredField = $members->toStructuredField(); 76 | 77 | return match (true) { 78 | $structuredField instanceof Dictionary, 79 | $structuredField instanceof Parameters => new self($structuredField->toAssociative()), 80 | default => throw new InvalidArgument('The '.StructuredFieldProvider::class.' must provide a structured field container; '.$structuredField::class.' given.'), 81 | }; 82 | } 83 | 84 | return new self($members); 85 | } 86 | 87 | /** 88 | * Returns a new instance from a pair iterable construct. 89 | * 90 | * Each member is composed of an array with two elements 91 | * the first member represents the instance entry key 92 | * the second member represents the instance entry value 93 | * 94 | * @param StructuredFieldProvider|iterable $pairs 95 | */ 96 | public static function fromPairs(StructuredFieldProvider|iterable $pairs): self 97 | { 98 | if ($pairs instanceof StructuredFieldProvider) { 99 | $pairs = $pairs->toStructuredField(); 100 | } 101 | 102 | if (!is_iterable($pairs)) { 103 | throw new InvalidArgument('The "'.$pairs::class.'" instance can not be used for creating a .'.self::class.' structured field.'); 104 | } 105 | 106 | return match (true) { 107 | $pairs instanceof Parameters, 108 | $pairs instanceof Dictionary => new self($pairs->toAssociative()), 109 | default => new self((function (iterable $pairs) { 110 | foreach ($pairs as [$key, $member]) { 111 | yield $key => $member; 112 | } 113 | })($pairs)), 114 | }; 115 | } 116 | 117 | /** 118 | * Returns an instance from an HTTP textual representation. 119 | * 120 | * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-3.1.2 121 | * 122 | * @throws SyntaxError|Exception If the string is not a valid 123 | */ 124 | public static function fromHttpValue(Stringable|string $httpValue, Ietf $rfc = Ietf::Rfc9651): self 125 | { 126 | return self::fromPairs((new Parser($rfc))->parseParameters($httpValue)); /* @phpstan-ignore-line */ 127 | } 128 | 129 | public static function fromRfc9651(Stringable|string $httpValue): self 130 | { 131 | return self::fromHttpValue($httpValue, Ietf::Rfc9651); 132 | } 133 | 134 | public static function fromRfc8941(Stringable|string $httpValue): self 135 | { 136 | return self::fromHttpValue($httpValue, Ietf::Rfc8941); 137 | } 138 | 139 | public function toHttpValue(Ietf $rfc = Ietf::Rfc9651): string 140 | { 141 | $formatter = static fn (Item $member, string $offset): string => match ($member->value()) { 142 | true => ';'.$offset, 143 | default => ';'.$offset.'='.$member->toHttpValue($rfc), 144 | }; 145 | 146 | return implode('', array_map($formatter, $this->members, array_keys($this->members))); 147 | } 148 | 149 | public function toRfc9651(): string 150 | { 151 | return $this->toHttpValue(Ietf::Rfc9651); 152 | } 153 | 154 | public function toRfc8941(): string 155 | { 156 | return $this->toHttpValue(Ietf::Rfc8941); 157 | } 158 | 159 | public function __toString(): string 160 | { 161 | return $this->toHttpValue(); 162 | } 163 | 164 | public function equals(mixed $other): bool 165 | { 166 | return $other instanceof self && $other->toHttpValue() === $this->toHttpValue(); 167 | } 168 | 169 | /** 170 | * Apply the callback if the given "condition" is (or resolves to) true. 171 | * 172 | * @param (callable($this): bool)|bool $condition 173 | * @param callable($this): (self|null) $onSuccess 174 | * @param ?callable($this): (self|null) $onFail 175 | */ 176 | public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): self 177 | { 178 | if (!is_bool($condition)) { 179 | $condition = $condition($this); 180 | } 181 | 182 | return match (true) { 183 | $condition => $onSuccess($this), 184 | null !== $onFail => $onFail($this), 185 | default => $this, 186 | } ?? $this; 187 | } 188 | 189 | public function count(): int 190 | { 191 | return count($this->members); 192 | } 193 | 194 | public function isEmpty(): bool 195 | { 196 | return !$this->isNotEmpty(); 197 | } 198 | 199 | public function isNotEmpty(): bool 200 | { 201 | return [] !== $this->members; 202 | } 203 | 204 | /** 205 | * @return Iterator 206 | */ 207 | public function toAssociative(): Iterator 208 | { 209 | yield from $this->members; 210 | } 211 | 212 | /** 213 | * @return Iterator 214 | */ 215 | public function getIterator(): Iterator 216 | { 217 | foreach ($this->members as $index => $member) { 218 | yield [$index, $member]; 219 | } 220 | } 221 | 222 | /** 223 | * @return array 224 | */ 225 | public function keys(): array 226 | { 227 | return array_keys($this->members); 228 | } 229 | 230 | /** 231 | * Tells whether the instance contain a members at the specified offsets. 232 | */ 233 | public function hasKeys(string ...$keys): bool 234 | { 235 | foreach ($keys as $key) { 236 | if (!array_key_exists($key, $this->members)) { 237 | return false; 238 | } 239 | } 240 | 241 | return [] !== $keys; 242 | } 243 | 244 | /** 245 | * @param ?callable(SfType): (bool|string) $validate 246 | * 247 | * @throws Violation|InvalidOffset 248 | */ 249 | public function getByKey(string $key, ?callable $validate = null): Item 250 | { 251 | $value = $this->members[$key] ?? throw InvalidOffset::dueToKeyNotFound($key); 252 | if (null === $validate || true === ($exceptionMessage = $validate($value->value()))) { 253 | return $value; 254 | } 255 | 256 | if (!is_string($exceptionMessage) || '' === trim($exceptionMessage)) { 257 | $exceptionMessage = "The parameter '{key}' whose value is '{value}' failed validation."; 258 | } 259 | 260 | throw new Violation(strtr($exceptionMessage, ['{key}' => $key, '{value}' => $value->toHttpValue()])); 261 | } 262 | 263 | /** 264 | * @return array 265 | */ 266 | public function indices(): array 267 | { 268 | return array_keys($this->keys()); 269 | } 270 | 271 | public function hasIndices(int ...$indices): bool 272 | { 273 | $max = count($this->members); 274 | foreach ($indices as $index) { 275 | if (null === $this->filterIndex($index, $max)) { 276 | return false; 277 | } 278 | } 279 | 280 | return [] !== $indices; 281 | } 282 | 283 | /** 284 | * Filters and format instance index. 285 | */ 286 | private function filterIndex(int $index, int|null $max = null): int|null 287 | { 288 | $max ??= count($this->members); 289 | 290 | return match (true) { 291 | [] === $this->members, 292 | 0 > $max + $index, 293 | 0 > $max - $index - 1 => null, 294 | 0 > $index => $max + $index, 295 | default => $index, 296 | }; 297 | } 298 | 299 | /** 300 | * @param ?callable(SfType, string): (bool|string) $validate 301 | * 302 | * @throws InvalidOffset|Violation 303 | * 304 | * @return array{0:string, 1:Item} 305 | */ 306 | public function getByIndex(int $index, ?callable $validate = null): array 307 | { 308 | $foundOffset = $this->filterIndex($index) ?? throw InvalidOffset::dueToIndexNotFound($index); 309 | foreach ($this as $offset => $pair) { 310 | if ($offset === $foundOffset) { 311 | break; 312 | } 313 | } 314 | 315 | if (!isset($pair)) { 316 | throw InvalidOffset::dueToIndexNotFound($index); 317 | } 318 | 319 | if (null === $validate || true === ($exceptionMessage = $validate($pair[1]->value(), $pair[0]))) { 320 | return $pair; 321 | } 322 | 323 | if (!is_string($exceptionMessage) || '' === trim($exceptionMessage)) { 324 | $exceptionMessage = "The parameter at position '{index}' whose key is '{key}' with the value '{value}' failed validation."; 325 | } 326 | 327 | throw new Violation(strtr($exceptionMessage, ['{index}' => $index, '{key}' => $pair[0], '{value}' => $pair[1]->toHttpValue()])); 328 | } 329 | 330 | /** 331 | * Returns the key associated with the given index or null otherwise. 332 | */ 333 | public function indexByKey(string $key): ?int 334 | { 335 | foreach ($this as $index => $member) { 336 | if ($key === $member[0]) { 337 | return $index; 338 | } 339 | } 340 | 341 | return null; 342 | } 343 | 344 | /** 345 | * Returns the index associated with the given key or null otherwise. 346 | */ 347 | public function keyByIndex(int $index): ?string 348 | { 349 | $index = $this->filterIndex($index); 350 | if (null === $index) { 351 | return null; 352 | } 353 | 354 | foreach ($this as $offset => $member) { 355 | if ($offset === $index) { 356 | return $member[0]; 357 | } 358 | } 359 | 360 | return null; 361 | } 362 | 363 | /** 364 | * @return array{0:string, 1:Item}|array{} 365 | */ 366 | public function first(): array 367 | { 368 | try { 369 | return $this->getByIndex(0); 370 | } catch (InvalidOffset) { 371 | return []; 372 | } 373 | } 374 | 375 | /** 376 | * @return array{0:string, 1:Item}|array{} 377 | */ 378 | public function last(): array 379 | { 380 | try { 381 | return $this->getByIndex(-1); 382 | } catch (InvalidOffset) { 383 | return []; 384 | } 385 | } 386 | 387 | /** 388 | * Returns true only if the instance only contains the listed keys, false otherwise. 389 | * 390 | * @param array $keys 391 | */ 392 | public function allowedKeys(array $keys): bool 393 | { 394 | foreach ($this->members as $key => $member) { 395 | if (!in_array($key, $keys, true)) { 396 | return false; 397 | } 398 | } 399 | 400 | return [] !== $keys; 401 | } 402 | 403 | /** 404 | * Returns the member value or null if no members value exists. 405 | * 406 | * @param ?callable(SfType): (bool|string) $validate 407 | * 408 | * @throws Violation if the validation fails 409 | * 410 | * @return SfType|null 411 | */ 412 | public function valueByKey( 413 | string $key, 414 | ?callable $validate = null, 415 | bool|string $required = false, 416 | Bytes|Token|DisplayString|DateTimeImmutable|string|int|float|bool|null $default = null 417 | ): Bytes|Token|DisplayString|DateTimeImmutable|string|int|float|bool|null { 418 | if (null !== $default && null === Type::tryFromVariable($default)) { 419 | throw new SyntaxError('The default parameter is invalid.'); 420 | } 421 | 422 | try { 423 | return $this->getByKey($key, $validate)->value(); 424 | } catch (InvalidOffset $exception) { 425 | if (false === $required) { 426 | return $default; 427 | } 428 | 429 | $message = $required; 430 | if (!is_string($message) || '' === trim($message)) { 431 | $message = "The required parameter '{key}' is missing."; 432 | } 433 | 434 | throw new Violation(strtr($message, ['{key}' => $key]), previous: $exception); 435 | } 436 | } 437 | 438 | /** 439 | * Returns the member value and key as pair or an empty array if no members value exists. 440 | * 441 | * @param ?callable(SfType, string): (bool|string) $validate 442 | * @param array{0:string, 1:SfType}|array{} $default 443 | * 444 | * @throws Violation if the validation fails 445 | * 446 | * @return array{0:string, 1:SfType}|array{} 447 | */ 448 | public function valueByIndex(int $index, ?callable $validate = null, bool|string $required = false, array $default = []): array 449 | { 450 | $default = match (true) { 451 | [] === $default => [], 452 | !array_is_list($default) => throw new SyntaxError('The pair must be represented by an array as a list.'), /* @phpstan-ignore-line */ 453 | 2 !== count($default) => throw new SyntaxError('The pair first member is the key; its second member is its value.'), /* @phpstan-ignore-line */ 454 | null === ($key = Key::tryFrom($default[0])?->value) => throw new SyntaxError('The pair first member is invalid.'), 455 | null === ($value = Item::tryNew($default[1])?->value()) => throw new SyntaxError('The pair second member is invalid.'), 456 | default => [$key, $value], 457 | }; 458 | 459 | try { 460 | $tuple = $this->getByIndex($index, $validate); 461 | 462 | return [$tuple[0], $tuple[1]->value()]; 463 | } catch (InvalidOffset $exception) { 464 | if (false === $required) { 465 | return $default; 466 | } 467 | 468 | $message = $required; 469 | if (!is_string($message) || '' === trim($message)) { 470 | $message = "The required parameter at position '{index}' is missing."; 471 | } 472 | 473 | throw new Violation(strtr($message, ['{index}' => $index]), previous: $exception); 474 | } 475 | } 476 | 477 | /** 478 | * @param StructuredFieldProvider|Item|SfType $member 479 | */ 480 | public function add( 481 | string $key, 482 | StructuredFieldProvider|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool $member 483 | ): self { 484 | $key = Key::from($key)->value; 485 | $member = Member::bareItem($member); 486 | $oldMember = $this->members[$key] ?? null; 487 | if (null === $oldMember || !$oldMember->equals($member)) { 488 | $members = $this->members; 489 | $members[$key] = $member; 490 | 491 | return new self($members); 492 | } 493 | 494 | return $this; 495 | } 496 | 497 | /** 498 | * @param array $members 499 | */ 500 | private function newInstance(array $members): self 501 | { 502 | foreach ($members as $offset => $member) { 503 | if (!isset($this->members[$offset]) || !$this->members[$offset]->equals($member)) { 504 | return new self($members); 505 | } 506 | } 507 | 508 | return $this; 509 | } 510 | 511 | private function remove(string|int ...$offsets): self 512 | { 513 | if ([] === $this->members || [] === $offsets) { 514 | return $this; 515 | } 516 | 517 | $keys = array_keys($this->members); 518 | $max = count($keys); 519 | $reducer = fn (array $carry, string|int $key): array => match (true) { 520 | is_string($key) && (false !== ($position = array_search($key, $keys, true))), 521 | is_int($key) && (null !== ($position = $this->filterIndex($key, $max))) => [$position => true] + $carry, 522 | default => $carry, 523 | }; 524 | 525 | $indices = array_reduce($offsets, $reducer, []); 526 | 527 | return match (true) { 528 | [] === $indices => $this, 529 | $max === count($indices) => self::new(), 530 | default => self::fromPairs((function (array $offsets) { 531 | foreach ($this as $offset => $pair) { 532 | if (!array_key_exists($offset, $offsets)) { 533 | yield $pair; 534 | } 535 | } 536 | })($indices)), 537 | }; 538 | } 539 | 540 | public function removeByIndices(int ...$indices): self 541 | { 542 | return $this->remove(...$indices); 543 | } 544 | 545 | public function removeByKeys(string ...$keys): self 546 | { 547 | return $this->remove(...$keys); 548 | } 549 | 550 | /** 551 | * @param StructuredFieldProvider|Item|SfType $member 552 | */ 553 | public function append( 554 | string $key, 555 | StructuredFieldProvider|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool $member 556 | ): self { 557 | $key = Key::from($key)->value; 558 | $member = Member::bareItem($member); 559 | $members = $this->members; 560 | unset($members[$key]); 561 | $members[$key] = $member; 562 | 563 | return $this->newInstance($members); 564 | } 565 | 566 | /** 567 | * @param StructuredFieldProvider|Item|SfType $member 568 | */ 569 | public function prepend( 570 | string $key, 571 | StructuredFieldProvider|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool $member 572 | ): self { 573 | $key = Key::from($key)->value; 574 | $member = Member::bareItem($member); 575 | $members = $this->members; 576 | unset($members[$key]); 577 | 578 | return $this->newInstance([$key => $member, ...$members]); 579 | } 580 | 581 | /** 582 | * @param array{0:string, 1:SfItemInput} ...$pairs 583 | */ 584 | public function push(array ...$pairs): self 585 | { 586 | return match (true) { 587 | [] === $pairs => $this, 588 | default => self::fromPairs((function (iterable $pairs) { 589 | yield from $this->getIterator(); 590 | yield from $pairs; 591 | })($pairs)), 592 | }; 593 | } 594 | 595 | /** 596 | * @param array{0:string, 1:SfItemInput} ...$pairs 597 | */ 598 | public function unshift(array ...$pairs): self 599 | { 600 | return match (true) { 601 | [] === $pairs => $this, 602 | default => self::fromPairs((function (iterable $pairs) { 603 | yield from $pairs; 604 | yield from $this->getIterator(); 605 | })($pairs)), 606 | }; 607 | } 608 | 609 | /** 610 | * @param array{0:string, 1:SfItemInput} ...$members 611 | */ 612 | public function insert(int $index, array ...$members): self 613 | { 614 | $offset = $this->filterIndex($index) ?? throw InvalidOffset::dueToIndexNotFound($index); 615 | 616 | return match (true) { 617 | [] === $members => $this, 618 | 0 === $offset => $this->unshift(...$members), 619 | count($this->members) === $offset => $this->push(...$members), 620 | default => (function (Iterator $newMembers) use ($offset, $members) { 621 | $newMembers = iterator_to_array($newMembers); 622 | array_splice($newMembers, $offset, 0, $members); 623 | 624 | return self::fromPairs($newMembers); 625 | })($this->getIterator()), 626 | }; 627 | } 628 | 629 | /** 630 | * @param array{0:string, 1:SfItemInput} $pair 631 | */ 632 | public function replace(int $index, array $pair): self 633 | { 634 | $offset = $this->filterIndex($index) ?? throw InvalidOffset::dueToIndexNotFound($index); 635 | $pair[1] = Member::bareItem($pair[1]); 636 | $pairs = iterator_to_array($this); 637 | 638 | return match (true) { 639 | $pairs[$offset][0] === $pair[0] && $pairs[$offset][1]->equals($pair[1]) => $this, 640 | default => self::fromPairs(array_replace($pairs, [$offset => $pair])), 641 | }; 642 | } 643 | 644 | /** 645 | * @param StructuredFieldProvider|Dictionary|Parameters|iterable ...$others 646 | */ 647 | public function mergeAssociative(StructuredFieldProvider|iterable ...$others): self 648 | { 649 | $members = $this->members; 650 | foreach ($others as $other) { 651 | if ($other instanceof StructuredFieldProvider) { 652 | $other = $other->toStructuredField(); 653 | if (!$other instanceof Dictionary && !$other instanceof Parameters) { 654 | throw new InvalidArgument('The "'.$other::class.'" instance can not be used for creating a .'.self::class.' structured field.'); 655 | } 656 | } 657 | 658 | if ($other instanceof self || $other instanceof Dictionary) { 659 | $other = $other->toAssociative(); 660 | } 661 | 662 | foreach ($other as $key => $value) { 663 | $members[$key] = $value; 664 | } 665 | } 666 | 667 | return new self($members); 668 | } 669 | 670 | /** 671 | * @param StructuredFieldProvider|Parameters|Dictionary|iterable ...$others 672 | */ 673 | public function mergePairs(Dictionary|Parameters|StructuredFieldProvider|iterable ...$others): self 674 | { 675 | $members = $this->members; 676 | foreach ($others as $other) { 677 | if (!$other instanceof self) { 678 | $other = self::fromPairs($other); 679 | } 680 | foreach ($other->toAssociative() as $key => $value) { 681 | $members[$key] = $value; 682 | } 683 | } 684 | 685 | return new self($members); 686 | } 687 | 688 | /** 689 | * @param string $offset 690 | */ 691 | public function offsetExists(mixed $offset): bool 692 | { 693 | return $this->hasKeys($offset); 694 | } 695 | 696 | /** 697 | * @param string $offset 698 | */ 699 | public function offsetGet(mixed $offset): Item 700 | { 701 | return $this->getByKey($offset); 702 | } 703 | 704 | public function offsetUnset(mixed $offset): void 705 | { 706 | throw new ForbiddenOperation(self::class.' instance can not be updated using '.ArrayAccess::class.' methods.'); 707 | } 708 | 709 | public function offsetSet(mixed $offset, mixed $value): void 710 | { 711 | throw new ForbiddenOperation(self::class.' instance can not be updated using '.ArrayAccess::class.' methods.'); 712 | } 713 | 714 | /** 715 | * @param callable(array{0:string, 1:Item}, int): TMap $callback 716 | * 717 | * @template TMap 718 | * 719 | * @return Iterator 720 | */ 721 | public function map(callable $callback): Iterator 722 | { 723 | foreach ($this as $offset => $pair) { 724 | yield ($callback)($pair, $offset); 725 | } 726 | } 727 | 728 | /** 729 | * @param callable(TInitial|null, array{0:string, 1:Item}, int): TInitial $callback 730 | * @param TInitial|null $initial 731 | * 732 | * @template TInitial 733 | * 734 | * @return TInitial|null 735 | */ 736 | public function reduce(callable $callback, mixed $initial = null): mixed 737 | { 738 | foreach ($this as $offset => $pair) { 739 | $initial = $callback($initial, $pair, $offset); 740 | } 741 | 742 | return $initial; 743 | } 744 | 745 | /** 746 | * @param callable(array{0:string, 1:Item}, int): bool $callback 747 | */ 748 | public function filter(callable $callback): self 749 | { 750 | return self::fromPairs(new CallbackFilterIterator($this->getIterator(), $callback)); 751 | } 752 | 753 | /** 754 | * @param callable(array{0:string, 1:Item}, array{0:string, 1:Item}): int $callback 755 | */ 756 | public function sort(callable $callback): self 757 | { 758 | $members = iterator_to_array($this); 759 | uasort($members, $callback); 760 | 761 | return self::fromPairs($members); 762 | } 763 | } 764 | -------------------------------------------------------------------------------- /src/Parser.php: -------------------------------------------------------------------------------- 1 | 39 | * @phpstan-type SfItem array{0:SfValue, 1: SfParameter} 40 | * @phpstan-type SfInnerList array{0:array, 1: SfParameter} 41 | */ 42 | final class Parser 43 | { 44 | private const REGEXP_BYTES = '/^(?:(?[a-z\d+\/=]*):)/i'; 45 | private const REGEXP_BOOLEAN = '/^\?[01]/'; 46 | private const REGEXP_DATE = '/^@(?-?\d{1,15})(?:[^\d.]|$)/'; 47 | private const REGEXP_DECIMAL = '/^-?\d{1,12}\.\d{1,3}$/'; 48 | private const REGEXP_INTEGER = '/^-?\d{1,15}$/'; 49 | private const REGEXP_TOKEN = "/^(?[a-z*][a-z\d:\/!#\$%&'*+\-.^_`|~]*)/i"; 50 | private const REGEXP_INVALID_CHARACTERS = "/[\r\t\n]|[^\x20-\x7E]/"; 51 | private const REGEXP_VALID_NUMBER = '/^(?-?\d+(?:\.\d+)?)(?:[^\d.]|$)/'; 52 | private const REGEXP_VALID_SPACE = '/^(?,[ \t]*)/'; 53 | private const FIRST_CHARACTER_RANGE_NUMBER = '-1234567890'; 54 | private const FIRST_CHARACTER_RANGE_TOKEN = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ*'; 55 | 56 | public function __construct(private readonly Ietf $rfc) 57 | { 58 | } 59 | 60 | /** 61 | * Returns an Item as a PHP list array from an HTTP textual representation. 62 | * 63 | * @see https://www.rfc-editor.org/rfc/rfc9651.html#name-parsing-an-item 64 | * 65 | * 66 | * @throws Exception|SyntaxError 67 | * 68 | * @return SfItem 69 | */ 70 | public function parseItem(Stringable|string $httpValue): array 71 | { 72 | $remainder = trim((string) $httpValue, ' '); 73 | if ('' === $remainder || 1 === preg_match(self::REGEXP_INVALID_CHARACTERS, $remainder)) { 74 | throw new SyntaxError("The HTTP textual representation \"$httpValue\" for an item contains invalid characters."); 75 | } 76 | 77 | [$value, $offset] = $this->extractValue($remainder); 78 | $remainder = substr($remainder, $offset); 79 | if ('' !== $remainder && !str_contains($remainder, ';')) { 80 | throw new SyntaxError("The HTTP textual representation \"$httpValue\" for an item contains invalid characters."); 81 | } 82 | 83 | return [$value, $this->parseParameters($remainder)]; /* @phpstan-ignore-line */ 84 | } 85 | 86 | /** 87 | * Returns a Parameters ordered map container as a PHP list array from an HTTP textual representation. 88 | * 89 | * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-3.1.2 90 | * 91 | * @throws SyntaxError|Exception 92 | * 93 | * @return array 94 | */ 95 | public function parseParameters(Stringable|string $httpValue): array 96 | { 97 | $remainder = trim((string) $httpValue); 98 | [$parameters, $offset] = $this->extractParametersValues($remainder); 99 | if (strlen($remainder) !== $offset) { 100 | throw new SyntaxError("The HTTP textual representation \"$httpValue\" for Parameters contains invalid characters."); 101 | } 102 | 103 | return $parameters; /* @phpstan-ignore-line */ 104 | } 105 | 106 | /** 107 | * Returns an ordered list represented as a PHP list array from an HTTP textual representation. 108 | * 109 | * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-4.2.1 110 | * 111 | * @throws SyntaxError|Exception 112 | * 113 | * @return array 114 | */ 115 | public function parseList(Stringable|string $httpValue): array 116 | { 117 | $list = []; 118 | $remainder = ltrim((string) $httpValue, ' '); 119 | while ('' !== $remainder) { 120 | [$list[], $offset] = $this->extractItemOrInnerList($remainder); 121 | $remainder = self::removeCommaSeparatedWhiteSpaces($remainder, $offset); 122 | } 123 | 124 | return $list; 125 | } 126 | 127 | /** 128 | * Returns a Dictionary represented as a PHP list array from an HTTP textual representation. 129 | * 130 | * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-4.2.2 131 | * 132 | * @throws SyntaxError|Exception 133 | * 134 | * @return array 135 | */ 136 | public function parseDictionary(Stringable|string $httpValue): array 137 | { 138 | $map = []; 139 | $remainder = ltrim((string) $httpValue, ' '); 140 | while ('' !== $remainder) { 141 | $key = Key::fromStringBeginning($remainder)->value; 142 | $remainder = substr($remainder, strlen($key)); 143 | if ('' === $remainder || '=' !== $remainder[0]) { 144 | $remainder = '=?1'.$remainder; 145 | } 146 | $member = [$key]; 147 | 148 | [$member[1], $offset] = $this->extractItemOrInnerList(substr($remainder, 1)); 149 | $remainder = self::removeCommaSeparatedWhiteSpaces($remainder, ++$offset); 150 | $map[] = $member; 151 | } 152 | 153 | return $map; 154 | } 155 | 156 | /** 157 | * Returns an inner list represented as a PHP list array from an HTTP textual representation. 158 | * 159 | * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-4.2.1.2 160 | * 161 | * @throws SyntaxError|Exception 162 | * 163 | * @return SfInnerList 164 | */ 165 | public function parseInnerList(Stringable|string $httpValue): array 166 | { 167 | $remainder = ltrim((string) $httpValue, ' '); 168 | if ('(' !== $remainder[0]) { 169 | throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a inner list is missing a parenthesis."); 170 | } 171 | 172 | [$list, $offset] = $this->extractInnerList($remainder); 173 | $remainder = self::removeOptionalWhiteSpaces(substr($remainder, $offset)); 174 | if ('' !== $remainder) { 175 | throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a inner list contains invalid data."); 176 | } 177 | 178 | return $list; 179 | } 180 | 181 | /** 182 | * Filter optional white spaces before and after comma. 183 | * 184 | * @see https://tools.ietf.org/html/rfc7230#section-3.2.3 185 | */ 186 | private static function removeCommaSeparatedWhiteSpaces(string $remainder, int $offset): string 187 | { 188 | $remainder = self::removeOptionalWhiteSpaces(substr($remainder, $offset)); 189 | if ('' === $remainder) { 190 | return ''; 191 | } 192 | 193 | if (1 !== preg_match(self::REGEXP_VALID_SPACE, $remainder, $found)) { 194 | throw new SyntaxError('The HTTP textual representation is missing an excepted comma.'); 195 | } 196 | 197 | $remainder = substr($remainder, strlen($found['space'])); 198 | 199 | if ('' === $remainder) { 200 | throw new SyntaxError('The HTTP textual representation has an unexpected end of line.'); 201 | } 202 | 203 | return $remainder; 204 | } 205 | 206 | /** 207 | * Remove optional white spaces before field value. 208 | * 209 | * @see https://tools.ietf.org/html/rfc7230#section-3.2.3 210 | */ 211 | private static function removeOptionalWhiteSpaces(string $httpValue): string 212 | { 213 | return ltrim($httpValue, " \t"); 214 | } 215 | 216 | /** 217 | * Returns an item or an inner list as a PHP list array from an HTTP textual representation. 218 | * 219 | * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-4.2.1.1 220 | * 221 | * @throws SyntaxError|Exception 222 | * 223 | * @return array{0: SfInnerList|SfItem, 1:int} 224 | */ 225 | private function extractItemOrInnerList(string $httpValue): array 226 | { 227 | if ('(' === $httpValue[0]) { 228 | return $this->extractInnerList($httpValue); 229 | } 230 | 231 | [$item, $remainder] = $this->extractItem($httpValue); 232 | 233 | return [$item, strlen($httpValue) - strlen($remainder)]; 234 | } 235 | 236 | /** 237 | * Returns an inner list represented as a PHP list array from an HTTP textual representation and the consumed offset in a tuple. 238 | * 239 | * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-4.2.1.2 240 | * 241 | * @throws SyntaxError|Exception 242 | * 243 | * @return array{0: SfInnerList, 1 :int} 244 | */ 245 | private function extractInnerList(string $httpValue): array 246 | { 247 | $list = []; 248 | $remainder = substr($httpValue, 1); 249 | while ('' !== $remainder) { 250 | $remainder = ltrim($remainder, ' '); 251 | 252 | if (')' === $remainder[0]) { 253 | $remainder = substr($remainder, 1); 254 | [$parameters, $offset] = $this->extractParametersValues($remainder); 255 | $remainder = substr($remainder, $offset); 256 | 257 | return [[$list, $parameters], strlen($httpValue) - strlen($remainder)]; 258 | } 259 | 260 | [$list[], $remainder] = $this->extractItem($remainder); 261 | 262 | if ('' !== $remainder && !in_array($remainder[0], [' ', ')'], true)) { 263 | throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a inner list is using invalid characters."); 264 | } 265 | } 266 | 267 | throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a inner list has an unexpected end of line."); 268 | } 269 | 270 | /** 271 | * Returns an item represented as a PHP array from an HTTP textual representation and the consumed offset in a tuple. 272 | * 273 | * @throws SyntaxError|Exception 274 | * 275 | * @return array{0:SfItem, 1:string} 276 | */ 277 | private function extractItem(string $remainder): array 278 | { 279 | [$value, $offset] = $this->extractValue($remainder); 280 | $remainder = substr($remainder, $offset); 281 | [$parameters, $offset] = $this->extractParametersValues($remainder); 282 | 283 | return [[$value, $parameters], substr($remainder, $offset)]; 284 | } 285 | 286 | /** 287 | * Returns an item value from an HTTP textual representation and the consumed offset in a tuple. 288 | * 289 | * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-4.2.3.1 290 | * 291 | * @throws SyntaxError|Exception 292 | * 293 | * @return array{0:SfValue, 1:int} 294 | */ 295 | private function extractValue(string $httpValue): array 296 | { 297 | return match (true) { 298 | '"' === $httpValue[0] => self::extractString($httpValue), 299 | ':' === $httpValue[0] => self::extractBytes($httpValue), 300 | '?' === $httpValue[0] => self::extractBoolean($httpValue), 301 | '@' === $httpValue[0] => self::extractDate($httpValue, $this->rfc), 302 | str_starts_with($httpValue, '%"') => self::extractDisplayString($httpValue, $this->rfc), 303 | str_contains(self::FIRST_CHARACTER_RANGE_NUMBER, $httpValue[0]) => self::extractNumber($httpValue), 304 | str_contains(self::FIRST_CHARACTER_RANGE_TOKEN, $httpValue[0]) => self::extractToken($httpValue), 305 | default => throw new SyntaxError("The HTTP textual representation \"$httpValue\" for an item value is unknown or unsupported."), 306 | }; 307 | } 308 | 309 | /** 310 | * Returns a parameters container represented as a PHP associative array from an HTTP textual representation and the consumed offset in a tuple. 311 | * 312 | * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-4.2.3.2 313 | * 314 | * @throws SyntaxError|Exception 315 | * 316 | * @return array{0:SfParameter, 1:int} 317 | */ 318 | private function extractParametersValues(Stringable|string $httpValue): array 319 | { 320 | $map = []; 321 | $httpValue = (string) $httpValue; 322 | $remainder = $httpValue; 323 | while ('' !== $remainder && ';' === $remainder[0]) { 324 | $remainder = ltrim(substr($remainder, 1), ' '); 325 | $key = Key::fromStringBeginning($remainder)->value; 326 | $member = [$key, true]; 327 | $remainder = substr($remainder, strlen($key)); 328 | if ('' !== $remainder && '=' === $remainder[0]) { 329 | $remainder = substr($remainder, 1); 330 | [$member[1], $offset] = $this->extractValue($remainder); 331 | $remainder = substr($remainder, $offset); 332 | } 333 | 334 | $map[] = $member; 335 | } 336 | 337 | return [$map, strlen($httpValue) - strlen($remainder)]; 338 | } 339 | 340 | /** 341 | * Returns a boolean from an HTTP textual representation and the consumed offset in a tuple. 342 | * 343 | * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-4.2.8 344 | * 345 | * @return array{0:bool, 1:int} 346 | */ 347 | private static function extractBoolean(string $httpValue): array 348 | { 349 | return match (1) { 350 | preg_match(self::REGEXP_BOOLEAN, $httpValue) => ['1' === $httpValue[1], 2], 351 | default => throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a Boolean contains invalid characters."), 352 | }; 353 | } 354 | 355 | /** 356 | * Returns an int or a float from an HTTP textual representation and the consumed offset in a tuple. 357 | * 358 | * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-4.2.4 359 | * 360 | * @return array{0:int|float, 1:int} 361 | */ 362 | private static function extractNumber(string $httpValue): array 363 | { 364 | if (1 !== preg_match(self::REGEXP_VALID_NUMBER, $httpValue, $found)) { 365 | throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a Number contains invalid characters."); 366 | } 367 | 368 | return match (1) { 369 | preg_match(self::REGEXP_DECIMAL, $found['number']) => [(float) $found['number'], strlen($found['number'])], 370 | preg_match(self::REGEXP_INTEGER, $found['number']) => [(int) $found['number'], strlen($found['number'])], 371 | default => throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a Number contains too much digit."), 372 | }; 373 | } 374 | 375 | /** 376 | * Returns DateTimeImmutable instance from an HTTP textual representation and the consumed offset in a tuple. 377 | * 378 | * @see https://httpwg.org/http-extensions/draft-ietf-httpbis-sfbis.html#name-dates 379 | * 380 | * @throws SyntaxError 381 | * @throws Exception 382 | * 383 | * @return array{0:DateTimeImmutable, 1:int} 384 | */ 385 | private static function extractDate(string $httpValue, Ietf $rfc): array 386 | { 387 | if (!$rfc->supports(Type::Date)) { 388 | throw MissingFeature::dueToLackOfSupport(Type::Date, $rfc); 389 | } 390 | 391 | if (1 !== preg_match(self::REGEXP_DATE, $httpValue, $found)) { 392 | throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a Date contains invalid characters."); 393 | } 394 | 395 | return [new DateTimeImmutable('@'.$found['date']), strlen($found['date']) + 1]; 396 | } 397 | 398 | /** 399 | * Returns a string from an HTTP textual representation and the consumed offset in a tuple. 400 | * 401 | * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-4.2.5 402 | * 403 | * @return array{0:string, 1:int} 404 | */ 405 | private static function extractString(string $httpValue): array 406 | { 407 | $offset = 1; 408 | $remainder = substr($httpValue, $offset); 409 | $output = ''; 410 | 411 | if (1 === preg_match(self::REGEXP_INVALID_CHARACTERS, $remainder)) { 412 | throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a String contains an invalid end string."); 413 | } 414 | 415 | while ('' !== $remainder) { 416 | $char = $remainder[0]; 417 | $offset += 1; 418 | 419 | if ('"' === $char) { 420 | return [$output, $offset]; 421 | } 422 | 423 | $remainder = substr($remainder, 1); 424 | 425 | if ('\\' !== $char) { 426 | $output .= $char; 427 | continue; 428 | } 429 | 430 | $char = $remainder[0] ?? ''; 431 | $offset += 1; 432 | $remainder = substr($remainder, 1); 433 | 434 | if (!in_array($char, ['"', '\\'], true)) { 435 | throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a String contains an invalid end string."); 436 | } 437 | 438 | $output .= $char; 439 | } 440 | 441 | throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a String contains an invalid end string."); 442 | } 443 | 444 | /** 445 | * Returns a string from an HTTP textual representation and the consumed offset in a tuple. 446 | * 447 | * @see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-sfbis#section-4.2.10 448 | * 449 | * @return array{0:DisplayString, 1:int} 450 | */ 451 | private static function extractDisplayString(string $httpValue, Ietf $rfc): array 452 | { 453 | if (!$rfc->supports(Type::DisplayString)) { 454 | throw MissingFeature::dueToLackOfSupport(Type::DisplayString, $rfc); 455 | } 456 | 457 | $offset = 2; 458 | $remainder = substr($httpValue, $offset); 459 | $output = ''; 460 | 461 | if (1 === preg_match(self::REGEXP_INVALID_CHARACTERS, $remainder)) { 462 | throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a DisplayString contains an invalid character string."); 463 | } 464 | 465 | while ('' !== $remainder) { 466 | $char = $remainder[0]; 467 | $offset += 1; 468 | 469 | if ('"' === $char) { 470 | return [DisplayString::fromEncoded($output), $offset]; 471 | } 472 | 473 | $remainder = substr($remainder, 1); 474 | if ('%' !== $char) { 475 | $output .= $char; 476 | continue; 477 | } 478 | 479 | $octet = substr($remainder, 0, 2); 480 | $offset += 2; 481 | if (1 === preg_match('/^[0-9a-f]]{2}$/', $octet)) { 482 | throw new SyntaxError("The HTTP textual representation '$httpValue' for a DisplayString contains uppercased percent encoding sequence."); 483 | } 484 | 485 | $remainder = substr($remainder, 2); 486 | $output .= $char.$octet; 487 | } 488 | 489 | throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a DisplayString contains an invalid end string."); 490 | } 491 | 492 | /** 493 | * Returns a Token from an HTTP textual representation and the consumed offset in a tuple. 494 | * 495 | * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-4.2.6 496 | * 497 | * @return array{0:Token, 1:int} 498 | */ 499 | private static function extractToken(string $httpValue): array 500 | { 501 | preg_match(self::REGEXP_TOKEN, $httpValue, $found); 502 | 503 | $token = $found['token'] ?? throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a Token contains invalid characters."); 504 | 505 | return [Token::fromString($token), strlen($token)]; 506 | } 507 | 508 | /** 509 | * Returns a Byte Sequence from an HTTP textual representation and the consumed offset in a tuple. 510 | * 511 | * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-4.2.7 512 | * 513 | * @return array{0:Bytes, 1:int} 514 | */ 515 | private static function extractBytes(string $httpValue): array 516 | { 517 | if (1 !== preg_match(self::REGEXP_BYTES, $httpValue, $found)) { 518 | throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a Byte Sequence contains invalid characters."); 519 | } 520 | 521 | return [Bytes::fromEncoded($found['byte']), strlen($found['sequence'])]; 522 | } 523 | } 524 | -------------------------------------------------------------------------------- /src/StructuredFieldError.php: -------------------------------------------------------------------------------- 1 | |SfItemInput 18 | * @phpstan-type SfParameterInput iterable 19 | * @phpstan-type SfInnerListPair array{0:iterable, 1?:Parameters|SfParameterInput} 20 | * @phpstan-type SfItemPair array{0:SfTypeInput, 1?:Parameters|SfParameterInput} 21 | */ 22 | interface StructuredFieldProvider 23 | { 24 | /** 25 | * Returns one of the StructuredField Data Type class. 26 | */ 27 | public function toStructuredField(): Dictionary|InnerList|Item|OuterList|Parameters; 28 | } 29 | -------------------------------------------------------------------------------- /src/SyntaxError.php: -------------------------------------------------------------------------------- 1 | value)) { 20 | throw new SyntaxError('The token '.$this->value.' contains invalid characters.'); 21 | } 22 | } 23 | 24 | public function toString(): string 25 | { 26 | return $this->value; 27 | } 28 | 29 | public static function tryFromString(Stringable|string $value): ?self 30 | { 31 | try { 32 | return self::fromString($value); 33 | } catch (Throwable) { 34 | return null; 35 | } 36 | } 37 | 38 | public static function fromString(Stringable|string $value): self 39 | { 40 | return new self((string)$value); 41 | } 42 | 43 | public function equals(mixed $other): bool 44 | { 45 | return $other instanceof self && $other->value === $this->value; 46 | } 47 | 48 | public function type(): Type 49 | { 50 | return Type::Token; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Type.php: -------------------------------------------------------------------------------- 1 | $other->type() === $this, 39 | default => $other instanceof self && $other === $this, 40 | }; 41 | } 42 | 43 | public function isOneOf(mixed ...$other): bool 44 | { 45 | foreach ($other as $item) { 46 | if ($this->equals($item)) { 47 | return true; 48 | } 49 | } 50 | 51 | return false; 52 | } 53 | 54 | /** 55 | * @throws SyntaxError if the value can not be resolved into a supported HTTP structured field data type 56 | */ 57 | public static function fromVariable(Item|Token|DisplayString|Bytes|DateTimeInterface|int|float|bool|string $value): self 58 | { 59 | return self::tryFromVariable($value) ?? throw new SyntaxError(match (true) { 60 | $value instanceof DateTimeInterface => 'The integer representation of a date is limited to 15 digits for a HTTP structured field date type.', 61 | is_int($value) => 'The integer is limited to 15 digits for a HTTP structured field integer type.', 62 | is_float($value) => 'The integer portion of decimals is limited to 12 digits for a HTTP structured field decimal type.', 63 | is_string($value) => 'The string contains characters that are invalid for a HTTP structured field string type', 64 | 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.', 65 | }); 66 | } 67 | 68 | public static function tryFromVariable(mixed $variable): ?self 69 | { 70 | return match (true) { 71 | $variable instanceof Item, 72 | $variable instanceof Token, 73 | $variable instanceof DisplayString, 74 | $variable instanceof Bytes => $variable->type(), 75 | $variable instanceof DateTimeInterface && self::MAXIMUM_INT >= abs($variable->getTimestamp()) => Type::Date, 76 | is_int($variable) && self::MAXIMUM_INT >= abs($variable) => Type::Integer, 77 | is_float($variable) && self::MAXIMUM_FLOAT >= abs(floor($variable)) => Type::Decimal, 78 | is_bool($variable) => Type::Boolean, 79 | is_string($variable) && 1 !== preg_match('/[^\x20-\x7f]/', $variable) => Type::String, 80 | default => null, 81 | }; 82 | } 83 | 84 | public function supports(mixed $value): bool 85 | { 86 | return self::tryFromVariable($value)?->equals($this) ?? false; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Validation/ErrorCode.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | public static function list(): array 23 | { 24 | return array_map(fn (self $case) => $case->value, self::cases()); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Validation/ItemValidator.php: -------------------------------------------------------------------------------- 1 | valueConstraint = $valueConstraint; 31 | $this->parametersConstraint = $parametersConstraint; 32 | } 33 | 34 | public static function new(): self 35 | { 36 | return new self(fn (mixed $value) => false, ParametersValidator::new()); 37 | } 38 | 39 | /** 40 | * Validates the Item value. 41 | * 42 | * On success populate the result item property 43 | * On failure populates the result errors property 44 | * 45 | * @param callable(SfType): (string|bool) $constraint 46 | */ 47 | public function value(callable $constraint): self 48 | { 49 | return new self($constraint, $this->parametersConstraint); 50 | } 51 | 52 | /** 53 | * Validates the Item parameters as a whole. 54 | * 55 | * On failure populates the result errors property 56 | */ 57 | public function parameters(ParametersValidator $constraint): self 58 | { 59 | return new self($this->valueConstraint, $constraint); 60 | } 61 | 62 | public function __invoke(Item|Stringable|string $item): bool|string 63 | { 64 | $result = $this->validate($item); 65 | 66 | return $result->isSuccess() ? true : (string) $result->errors; 67 | } 68 | 69 | /** 70 | * Validates the structured field Item. 71 | */ 72 | public function validate(Item|Stringable|string $item): Result 73 | { 74 | $violations = new ViolationList(); 75 | if (!$item instanceof Item) { 76 | try { 77 | $item = Item::fromHttpValue($item); 78 | } catch (SyntaxError $exception) { 79 | $violations->add(ErrorCode::ItemFailedParsing->value, new Violation('The item string could not be parsed.', previous: $exception)); 80 | 81 | return Result::failed($violations); 82 | } 83 | } 84 | 85 | try { 86 | $itemValue = $item->value($this->valueConstraint); 87 | } catch (Violation $exception) { 88 | $itemValue = null; 89 | $violations->add(ErrorCode::ItemValueFailedValidation->value, $exception); 90 | } 91 | 92 | $validate = $this->parametersConstraint->validate($item->parameters()); 93 | $violations->addAll($validate->errors); 94 | if ($violations->isNotEmpty()) { 95 | return Result::failed($violations); 96 | } 97 | 98 | /** @var ValidatedParameters $validatedParameters */ 99 | $validatedParameters = $validate->data; 100 | 101 | return Result::success(new ValidatedItem($itemValue, $validatedParameters)); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Validation/ParametersValidator.php: -------------------------------------------------------------------------------- 1 | |array */ 29 | private array $filterConstraints; 30 | 31 | /** 32 | * @param ?callable(Parameters): (string|bool) $criteria 33 | * @param array|array $filterConstraints 34 | */ 35 | private function __construct( 36 | ?callable $criteria = null, 37 | int $type = self::USE_KEYS, 38 | array $filterConstraints = [], 39 | ) { 40 | $this->criteria = $criteria; 41 | $this->type = $type; 42 | $this->filterConstraints = $filterConstraints; 43 | } 44 | 45 | public static function new(): self 46 | { 47 | return new self(); 48 | } 49 | 50 | /** 51 | * Validates the Item parameters as a whole. 52 | * 53 | * On failure populates the result errors property 54 | * 55 | * @param ?callable(Parameters): (string|bool) $criteria 56 | */ 57 | public function filterByCriteria(?callable $criteria, int $type = self::USE_KEYS): self 58 | { 59 | return new self($criteria, [] === $this->filterConstraints ? $type : $this->type, $this->filterConstraints); 60 | } 61 | 62 | /** 63 | * Validate each parameters value per name. 64 | * 65 | * On success populate the result item property 66 | * On failure populates the result errors property 67 | * 68 | * @param array $constraints 69 | */ 70 | public function filterByKeys(array $constraints): self 71 | { 72 | return new self($this->criteria, self::USE_KEYS, $constraints); 73 | } 74 | 75 | /** 76 | * Validate each parameters value per indices. 77 | * 78 | * On success populate the result item property 79 | * On failure populates the result errors property 80 | * 81 | * @param array $constraints 82 | */ 83 | public function filterByIndices(array $constraints): self 84 | { 85 | return new self($this->criteria, self::USE_INDICES, $constraints); 86 | } 87 | 88 | public function __invoke(Parameters|Stringable|string $parameters): bool|string 89 | { 90 | $result = $this->validate($parameters); 91 | 92 | return $result->isSuccess() ? true : (string) $result->errors; 93 | } 94 | 95 | /** 96 | * Validates the structured field Item. 97 | */ 98 | public function validate(Parameters|Stringable|string $parameters): Result 99 | { 100 | $violations = new ViolationList(); 101 | if (!$parameters instanceof Parameters) { 102 | try { 103 | $parameters = Parameters::fromHttpValue($parameters); 104 | } catch (SyntaxError $exception) { 105 | $violations->add(ErrorCode::ParametersFailedParsing->value, new Violation('The parameters string could not be parsed.', previous: $exception)); 106 | 107 | return Result::failed($violations); 108 | } 109 | } 110 | 111 | if ([] === $this->filterConstraints && null === $this->criteria) { 112 | $violations->add(ErrorCode::ParametersMissingConstraints->value, new Violation('The parameters constraints are missing.')); 113 | } 114 | 115 | $parsedParameters = new ValidatedParameters(); 116 | if ([] !== $this->filterConstraints) { 117 | $parsedParameters = match ($this->type) { 118 | self::USE_INDICES => $this->validateByIndices($parameters), 119 | default => $this->validateByKeys($parameters), 120 | }; 121 | 122 | if ($parsedParameters->isFailed()) { 123 | $violations->addAll($parsedParameters->errors); 124 | } else { 125 | $parsedParameters = $parsedParameters->data; 126 | } 127 | } 128 | 129 | $errorMessage = $this->validateByCriteria($parameters); 130 | if (!is_bool($errorMessage)) { 131 | $violations->add(ErrorCode::ParametersFailedCriteria->value, new Violation($errorMessage)); 132 | } 133 | 134 | /** @var ValidatedParameters $parsedParameters */ 135 | $parsedParameters = $parsedParameters ?? new ValidatedParameters(); 136 | if ([] === $this->filterConstraints && true === $errorMessage) { 137 | $parsedParameters = new ValidatedParameters(match ($this->type) { 138 | self::USE_KEYS => $this->toAssociative($parameters), 139 | default => $this->toList($parameters), 140 | }); 141 | } 142 | 143 | return match ($violations->isNotEmpty()) { 144 | true => Result::failed($violations), 145 | default => Result::success($parsedParameters), 146 | }; 147 | } 148 | 149 | private function validateByCriteria(Parameters $parameters): bool|string 150 | { 151 | if (null === $this->criteria) { 152 | return true; 153 | } 154 | 155 | $errorMessage = ($this->criteria)($parameters); 156 | if (true === $errorMessage) { 157 | return true; 158 | } 159 | 160 | if (!is_string($errorMessage) || '' === trim($errorMessage)) { 161 | $errorMessage = 'The parameters constraints are not met.'; 162 | } 163 | 164 | return $errorMessage; 165 | } 166 | 167 | /** 168 | * Validate the current parameter object using its keys and return the parsed values and the errors. 169 | * 170 | * @return Result|Result 171 | */ 172 | private function validateByKeys(Parameters $parameters): Result /* @phpstan-ignore-line */ 173 | { 174 | $data = []; 175 | $violations = new ViolationList(); 176 | /** 177 | * @var string $key 178 | * @var SfParameterKeyRule $rule 179 | */ 180 | foreach ($this->filterConstraints as $key => $rule) { 181 | try { 182 | $data[$key] = $parameters->valueByKey($key, $rule['validate'] ?? null, $rule['required'] ?? false, $rule['default'] ?? null); 183 | } catch (Violation $exception) { 184 | $violations[$key] = $exception; 185 | } 186 | } 187 | 188 | return match ($violations->isNotEmpty()) { 189 | true => Result::failed($violations), 190 | default => Result::success(new ValidatedParameters($data)), 191 | }; 192 | } 193 | 194 | /** 195 | * Validate the current parameter object using its indices and return the parsed values and the errors. 196 | */ 197 | public function validateByIndices(Parameters $parameters): Result 198 | { 199 | $data = []; 200 | $violations = new ViolationList(); 201 | /** 202 | * @var int $index 203 | * @var SfParameterIndexRule $rule 204 | */ 205 | foreach ($this->filterConstraints as $index => $rule) { 206 | try { 207 | $data[$index] = $parameters->valueByIndex($index, $rule['validate'] ?? null, $rule['required'] ?? false, $rule['default'] ?? []); 208 | } catch (Violation $exception) { 209 | $violations[$index] = $exception; 210 | } 211 | } 212 | 213 | return match ($violations->isNotEmpty()) { 214 | true => Result::failed($violations), 215 | default => Result::success(new ValidatedParameters($data)), 216 | }; 217 | } 218 | 219 | /** 220 | * @return array 221 | */ 222 | private function toAssociative(Parameters $parameters): array 223 | { 224 | $assoc = []; 225 | foreach ($parameters as $parameter) { 226 | $assoc[$parameter[0]] = $parameter[1]->value(); 227 | } 228 | 229 | return $assoc; 230 | } 231 | 232 | /** 233 | * @return array 234 | */ 235 | private function toList(Parameters $parameters): array 236 | { 237 | $list = []; 238 | foreach ($parameters as $index => $parameter) { 239 | $list[$index] = [$parameter[0], $parameter[1]->value()]; 240 | } 241 | 242 | return $list; 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /src/Validation/Result.php: -------------------------------------------------------------------------------- 1 | errors->isEmpty(); 18 | } 19 | 20 | public function isFailed(): bool 21 | { 22 | return $this->errors->isNotEmpty(); 23 | } 24 | 25 | public static function success(ValidatedItem|ValidatedParameters $data): self 26 | { 27 | return new self($data, new ViolationList()); 28 | } 29 | 30 | public static function failed(ViolationList $errors): self 31 | { 32 | return new self(null, $errors); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Validation/ValidatedItem.php: -------------------------------------------------------------------------------- 1 | 19 | * @implements IteratorAggregate 20 | */ 21 | final class ValidatedParameters implements ArrayAccess, Countable, IteratorAggregate 22 | { 23 | /** 24 | * @param array $values 25 | */ 26 | public function __construct( 27 | private readonly array $values = [], 28 | ) { 29 | } 30 | 31 | public function count(): int 32 | { 33 | return count($this->values); 34 | } 35 | 36 | public function getIterator(): Iterator 37 | { 38 | yield from $this->values; 39 | } 40 | 41 | public function offsetExists($offset): bool 42 | { 43 | return array_key_exists($offset, $this->values); 44 | } 45 | 46 | public function offsetGet($offset): mixed 47 | { 48 | return $this->offsetExists($offset) ? $this->values[$offset] : throw InvalidOffset::dueToMemberNotFound($offset); 49 | } 50 | 51 | public function offsetUnset(mixed $offset): void 52 | { 53 | throw new ForbiddenOperation(self::class.' instance can not be updated using '.ArrayAccess::class.' methods.'); 54 | } 55 | 56 | public function offsetSet(mixed $offset, mixed $value): void 57 | { 58 | throw new ForbiddenOperation(self::class.' instance can not be updated using '.ArrayAccess::class.' methods.'); 59 | } 60 | 61 | /** 62 | * @return array 63 | */ 64 | public function all(): array 65 | { 66 | return $this->values; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Validation/Violation.php: -------------------------------------------------------------------------------- 1 | 25 | * @implements ArrayAccess 26 | */ 27 | final class ViolationList implements IteratorAggregate, Countable, ArrayAccess, Stringable 28 | { 29 | /** @var array */ 30 | private array $errors = []; 31 | 32 | /** 33 | * @param iterable $errors 34 | */ 35 | public function __construct(iterable $errors = []) 36 | { 37 | $this->addAll($errors); 38 | } 39 | 40 | public function count(): int 41 | { 42 | return count($this->errors); 43 | } 44 | 45 | public function getIterator(): Iterator 46 | { 47 | yield from $this->errors; 48 | } 49 | 50 | public function __toString(): string 51 | { 52 | return implode(PHP_EOL, array_map(fn (Violation $e): string => $e->getMessage(), $this->errors)); 53 | } 54 | 55 | /** 56 | * @return array 57 | */ 58 | public function summary(): array 59 | { 60 | return array_map(fn (Violation $e): string => $e->getMessage(), $this->errors); 61 | } 62 | 63 | public function isEmpty(): bool 64 | { 65 | return [] === $this->errors; 66 | } 67 | 68 | public function isNotEmpty(): bool 69 | { 70 | return ! $this->isEmpty(); 71 | } 72 | 73 | /** 74 | * @param string|int $offset 75 | */ 76 | public function offsetExists(mixed $offset): bool 77 | { 78 | return $this->has($offset); 79 | } 80 | 81 | /** 82 | * @param string|int $offset 83 | * 84 | * @return Violation 85 | */ 86 | public function offsetGet(mixed $offset): mixed 87 | { 88 | return $this->get($offset); 89 | } 90 | 91 | /** 92 | * @param string|int $offset 93 | */ 94 | public function offsetUnset(mixed $offset): void 95 | { 96 | unset($this->errors[$offset]); 97 | } 98 | 99 | /** 100 | * @param string|int|null $offset 101 | * @param Violation $value 102 | */ 103 | public function offsetSet(mixed $offset, mixed $value): void 104 | { 105 | if (null === $offset) { 106 | throw new TypeError('null can not be used as a valid offset value.'); 107 | } 108 | $this->add($offset, $value); 109 | } 110 | 111 | public function has(string|int $offset): bool 112 | { 113 | if (is_int($offset)) { 114 | return null !== $this->filterIndex($offset); 115 | } 116 | 117 | return array_key_exists($offset, $this->errors); 118 | } 119 | 120 | public function get(string|int $offset): Violation 121 | { 122 | return $this->errors[$this->filterIndex($offset) ?? throw InvalidOffset::dueToIndexNotFound($offset)]; 123 | } 124 | 125 | public function add(string|int $offset, Violation $error): void 126 | { 127 | $this->errors[$offset] = $error; 128 | } 129 | 130 | /** 131 | * @param iterable $errors 132 | */ 133 | public function addAll(iterable $errors): void 134 | { 135 | foreach ($errors as $offset => $error) { 136 | $this->add($offset, $error); 137 | } 138 | } 139 | 140 | private function filterIndex(string|int $index, int|null $max = null): string|int|null 141 | { 142 | if (!is_int($index)) { 143 | return $index; 144 | } 145 | 146 | $max ??= count($this->errors); 147 | 148 | return match (true) { 149 | [] === $this->errors, 150 | 0 > $max + $index, 151 | 0 > $max - $index - 1 => null, 152 | 0 > $index => $max + $index, 153 | default => $index, 154 | }; 155 | } 156 | 157 | /** 158 | * @param callable(Violation, array-key): bool $callback 159 | */ 160 | public function filter(callable $callback): self 161 | { 162 | return new self(array_filter($this->errors, $callback, ARRAY_FILTER_USE_BOTH)); 163 | } 164 | 165 | public function toException(): Violation 166 | { 167 | return new Violation((string) $this); 168 | } 169 | } 170 | --------------------------------------------------------------------------------