├── README.md ├── composer.json └── src ├── Error.php ├── Parser.php ├── ParserErrorException.php ├── ParserErrorExceptionToString.php ├── ParserInterface.php ├── Result.php └── Schema ├── AbstractSchema.php ├── ArraySchema.php ├── BackedEnumSchema.php ├── BoolSchema.php ├── DateTimeSchema.php ├── DiscriminatedUnionSchema.php ├── FloatSchema.php ├── IntSchema.php ├── LazySchema.php ├── LiteralSchema.php ├── ObjectSchema.php ├── ObjectSchemaInterface.php ├── RecordSchema.php ├── RespectValidationSchema.php ├── SchemaInterface.php ├── StringSchema.php ├── TupleSchema.php └── UnionSchema.php /README.md: -------------------------------------------------------------------------------- 1 | # chubbyphp-parsing 2 | 3 | [![CI](https://github.com/chubbyphp/chubbyphp-parsing/actions/workflows/ci.yml/badge.svg)](https://github.com/chubbyphp/chubbyphp-parsing/actions/workflows/ci.yml) 4 | [![Coverage Status](https://coveralls.io/repos/github/chubbyphp/chubbyphp-parsing/badge.svg?branch=master)](https://coveralls.io/github/chubbyphp/chubbyphp-parsing?branch=master) 5 | [![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fchubbyphp%2Fchubbyphp-parsing%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/chubbyphp/chubbyphp-parsing/master) 6 | [![Latest Stable Version](https://poser.pugx.org/chubbyphp/chubbyphp-parsing/v)](https://packagist.org/packages/chubbyphp/chubbyphp-parsing) 7 | [![Total Downloads](https://poser.pugx.org/chubbyphp/chubbyphp-parsing/downloads)](https://packagist.org/packages/chubbyphp/chubbyphp-parsing) 8 | [![Monthly Downloads](https://poser.pugx.org/chubbyphp/chubbyphp-parsing/d/monthly)](https://packagist.org/packages/chubbyphp/chubbyphp-parsing) 9 | 10 | [![bugs](https://sonarcloud.io/api/project_badges/measure?project=chubbyphp_chubbyphp-parsing&metric=bugs)](https://sonarcloud.io/dashboard?id=chubbyphp_chubbyphp-parsing) 11 | [![code_smells](https://sonarcloud.io/api/project_badges/measure?project=chubbyphp_chubbyphp-parsing&metric=code_smells)](https://sonarcloud.io/dashboard?id=chubbyphp_chubbyphp-parsing) 12 | [![coverage](https://sonarcloud.io/api/project_badges/measure?project=chubbyphp_chubbyphp-parsing&metric=coverage)](https://sonarcloud.io/dashboard?id=chubbyphp_chubbyphp-parsing) 13 | [![duplicated_lines_density](https://sonarcloud.io/api/project_badges/measure?project=chubbyphp_chubbyphp-parsing&metric=duplicated_lines_density)](https://sonarcloud.io/dashboard?id=chubbyphp_chubbyphp-parsing) 14 | [![ncloc](https://sonarcloud.io/api/project_badges/measure?project=chubbyphp_chubbyphp-parsing&metric=ncloc)](https://sonarcloud.io/dashboard?id=chubbyphp_chubbyphp-parsing) 15 | [![sqale_rating](https://sonarcloud.io/api/project_badges/measure?project=chubbyphp_chubbyphp-parsing&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=chubbyphp_chubbyphp-parsing) 16 | [![alert_status](https://sonarcloud.io/api/project_badges/measure?project=chubbyphp_chubbyphp-parsing&metric=alert_status)](https://sonarcloud.io/dashboard?id=chubbyphp_chubbyphp-parsing) 17 | [![reliability_rating](https://sonarcloud.io/api/project_badges/measure?project=chubbyphp_chubbyphp-parsing&metric=reliability_rating)](https://sonarcloud.io/dashboard?id=chubbyphp_chubbyphp-parsing) 18 | [![security_rating](https://sonarcloud.io/api/project_badges/measure?project=chubbyphp_chubbyphp-parsing&metric=security_rating)](https://sonarcloud.io/dashboard?id=chubbyphp_chubbyphp-parsing) 19 | [![sqale_index](https://sonarcloud.io/api/project_badges/measure?project=chubbyphp_chubbyphp-parsing&metric=sqale_index)](https://sonarcloud.io/dashboard?id=chubbyphp_chubbyphp-parsing) 20 | [![vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=chubbyphp_chubbyphp-parsing&metric=vulnerabilities)](https://sonarcloud.io/dashboard?id=chubbyphp_chubbyphp-parsing) 21 | 22 | 23 | ## Description 24 | 25 | Allows parsing data of various structures, meaning the population and validation of data into a defined structure. For example, converting an API request into a Data Transfer Object (DTO). 26 | 27 | Heavily inspired by the well-known TypeScript library [zod](https://github.com/colinhacks/zod). 28 | 29 | ## Requirements 30 | 31 | * php: ^8.2 32 | 33 | ## Installation 34 | 35 | Through [Composer](http://getcomposer.org) as [chubbyphp/chubbyphp-parsing][1]. 36 | 37 | ```sh 38 | composer require chubbyphp/chubbyphp-parsing "^1.4" 39 | ``` 40 | 41 | ## Usage 42 | 43 | ```php 44 | use Chubbyphp\Parsing\Schema\SchemaInterface; 45 | 46 | /** @var SchemaInterface $schema */ 47 | $schema = ...; 48 | 49 | $schema->nullable(); 50 | $schema->preParse(static fn ($input) => $input); 51 | $schema->postParse(static fn (string $output) => $output); 52 | $schema->parse('test'); 53 | $schema->safeParse('test'); 54 | $schema->catch(static fn (string $output, ParserErrorException $e) => $output); 55 | ``` 56 | 57 | ### array 58 | 59 | ```php 60 | use Chubbyphp\Parsing\Parser; 61 | 62 | $p = new Parser(); 63 | 64 | $schema = $p->array($p->int()); 65 | 66 | $data = $schema->parse([1, 2, 3, 4, 5]); 67 | 68 | // validations 69 | $schema->length(5); 70 | $schema->minLength(5); 71 | $schema->maxLength(5); 72 | $schema->includes(5); 73 | 74 | // transformations 75 | $schema->filter(static fn (int $value) => 0 === $value % 2); 76 | $schema->map(static fn (int $value) => $value * 2); 77 | $schema->sort(); 78 | $schema->sort(static fn (int $a, int $b) => $b - $a); 79 | 80 | // conversions 81 | $schema->reduce(static fn (int $sum, int $current) => $sum + $current, 0); 82 | ``` 83 | 84 | ### backedEnum 85 | 86 | ```php 87 | use Chubbyphp\Parsing\Parser; 88 | 89 | enum BackedSuit: string 90 | { 91 | case Hearts = 'H'; 92 | case Diamonds = 'D'; 93 | case Clubs = 'C'; 94 | case Spades = 'S'; 95 | } 96 | 97 | $p = new Parser(); 98 | 99 | $schema = $p->backedEnum(BackedSuit::class); 100 | 101 | $data = $schema->parse('D'); 102 | 103 | // validations 104 | 105 | // transformations 106 | 107 | // conversions 108 | $schema->toInt(); 109 | $schema->toString(); 110 | ``` 111 | 112 | ### bool 113 | 114 | ```php 115 | use Chubbyphp\Parsing\Parser; 116 | 117 | $p = new Parser(); 118 | 119 | $schema = $p->bool(); 120 | 121 | $data = $schema->parse(true); 122 | 123 | // validations 124 | 125 | // transformations 126 | 127 | // conversions 128 | $schema->toInt(); 129 | $schema->toString(); 130 | ``` 131 | 132 | ### dateTime 133 | 134 | ```php 135 | use Chubbyphp\Parsing\Parser; 136 | 137 | $p = new Parser(); 138 | 139 | $schema = $p->dateTime(); 140 | 141 | $data = $schema->parse(new \DateTimeImmutable('2024-01-20T09:15:00+00:00')); 142 | 143 | // validations 144 | $schema->from(new \DateTimeImmutable('2024-01-20T09:15:00+00:00')); 145 | $schema->to(new \DateTimeImmutable('2024-01-20T09:15:00+00:00')); 146 | 147 | // transformations 148 | 149 | // conversions 150 | $schema->toInt(); 151 | $schema->toString(); 152 | ``` 153 | 154 | ### discriminatedUnion 155 | 156 | ```php 157 | use Chubbyphp\Parsing\Parser; 158 | 159 | $p = new Parser(); 160 | 161 | $schema = $p->discriminatedUnion([ 162 | $p->object(['_type' => $p->literal('email'), 'address' => $p->string()]), 163 | $p->object(['_type' => $p->literal('phone'), 'number' => $p->string()]), 164 | ]); 165 | 166 | $data = $schema->parse(['_type' => 'phone', 'number' => '+41790000000']); 167 | ``` 168 | 169 | ### float 170 | 171 | ```php 172 | use Chubbyphp\Parsing\Parser; 173 | 174 | $p = new Parser(); 175 | 176 | $schema = $p->float(); 177 | 178 | $data = $schema->parse(4.2); 179 | 180 | // validations 181 | $schema->gt(5.0); 182 | $schema->gte(5.0); 183 | $schema->lt(5.0); 184 | $schema->lte(5.0); 185 | $schema->positive(); 186 | $schema->nonNegative(); 187 | $schema->negative(); 188 | $schema->nonPositive(); 189 | 190 | // transformations 191 | 192 | // conversions 193 | $schema->toInt(); 194 | $schema->toString(); 195 | ``` 196 | 197 | ### int 198 | 199 | ```php 200 | use Chubbyphp\Parsing\Parser; 201 | 202 | $p = new Parser(); 203 | 204 | $schema = $p->int(); 205 | 206 | $data = $schema->parse(1337); 207 | 208 | // validations 209 | $schema->gt(5); 210 | $schema->gte(5); 211 | $schema->lt(5); 212 | $schema->lte(5); 213 | $schema->positive(); 214 | $schema->nonNegative(); 215 | $schema->negative(); 216 | $schema->nonPositive(); 217 | 218 | // transformations 219 | 220 | // conversions 221 | $schema->toDateTime(); 222 | $schema->toFloat(); 223 | $schema->toString(); 224 | ``` 225 | 226 | ### lazy 227 | 228 | ```php 229 | use Chubbyphp\Parsing\Parser; 230 | 231 | $p = new Parser(); 232 | 233 | $schema = $p->lazy(static function () use ($p, &$schema) { 234 | return $p->object([ 235 | 'name' => $p->string(), 236 | 'child' => $schema, 237 | ])->nullable(); 238 | }); 239 | 240 | $data = $schema->parse([ 241 | 'name' => 'name1', 242 | 'child' => [ 243 | 'name' => 'name2', 244 | 'child' => null 245 | ], 246 | ]); 247 | ``` 248 | 249 | ### literal 250 | 251 | ```php 252 | use Chubbyphp\Parsing\Parser; 253 | 254 | $p = new Parser(); 255 | 256 | $schema = $p->literal('email'); // supports string|float|int|bool 257 | 258 | $data = $schema->parse('email'); 259 | ``` 260 | 261 | ### object 262 | 263 | ```php 264 | use Chubbyphp\Parsing\Parser; 265 | 266 | $p = new Parser(); 267 | 268 | // stdClass example 269 | $schema = $p->object(['name' => $p->string()]); 270 | $object = $schema->parse(['name' => 'example']); 271 | 272 | // SampleNamespace\SampleClass example 273 | $schema = $p->object(['name' => $p->string()], SampleNamespace\SampleClass::class); 274 | $object = $schema->parse(['name' => 'example']); 275 | 276 | // getFieldToSchema 277 | $schema = $p->object(['name' => $p->string()]); 278 | $extendedSchema = $p->object([...$schema->getFieldToSchema(), 'value' => $p->string()]); 279 | 280 | // getFieldSchema 281 | $schema = $p->object(['name' => $p->string()]); 282 | $nameSchema = $schema->getFieldSchema('name'); 283 | 284 | // if the key 'name' does not exist on input, it won't exists on the output 285 | $schema->optional(['name']); 286 | 287 | // validations 288 | $schema->strict(); 289 | $schema->strict(['_id']); // strip _id if given, but complain about any other additional field 290 | 291 | // transformations 292 | 293 | // conversions 294 | ``` 295 | 296 | ### record 297 | 298 | ```php 299 | use Chubbyphp\Parsing\Parser; 300 | 301 | $p = new Parser(); 302 | 303 | $schema = $p->record($p->string()); 304 | 305 | $data = $schema->parse([ 306 | 'key1' => 'value1', 307 | 'key2' => 'value2' 308 | ]); 309 | ``` 310 | 311 | ### respectValidation 312 | 313 | ```sh 314 | composer require respect/validation "^2.4" 315 | ``` 316 | 317 | ```php 318 | use Chubbyphp\Parsing\Parser; 319 | use Respect\Validation\Validator as v; 320 | 321 | $p = new Parser(); 322 | 323 | $schema = $p->respectValidation(v::numericVal()->positive()->between(1, 255)); 324 | 325 | $data = $schema->parse(5); 326 | ``` 327 | 328 | ### string 329 | 330 | ```php 331 | use Chubbyphp\Parsing\Parser; 332 | 333 | $p = new Parser(); 334 | 335 | $schema = $p->string(); 336 | 337 | $data = $schema->parse('example'); 338 | 339 | // validations 340 | $schema->length(5); 341 | $schema->minLength(5); 342 | $schema->maxLength(5); 343 | $schema->includes('amp'); 344 | $schema->startsWith('exa'); 345 | $schema->endsWith('mpl'); 346 | $schema->match('/^[a-z]+$/i'); 347 | $schema->email(); 348 | $schema->ipV4(); 349 | $schema->ipV6(); 350 | $schema->url(); 351 | $schema->uuidV4(); 352 | $schema->uuidV5(); 353 | 354 | // transformations 355 | $schema->trim(); 356 | $schema->trimStart(); 357 | $schema->trimEnd(); 358 | $schema->toLowerCase(); 359 | $schema->toUpperCase(); 360 | 361 | // conversions 362 | $schema->toDateTime(); 363 | $schema->toFloat(); 364 | $schema->toInt(); 365 | 366 | // examples 367 | $notBlankSchema = $schema->trim()->minSize(1); 368 | ``` 369 | 370 | ### tuple 371 | 372 | ```php 373 | use Chubbyphp\Parsing\Parser; 374 | 375 | $p = new Parser(); 376 | 377 | $schema = $p->tuple([$p->float(), $p->float()]); 378 | 379 | $data = $schema->parse([47.1, 8.2]); 380 | ``` 381 | 382 | ### union 383 | 384 | ```php 385 | use Chubbyphp\Parsing\Parser; 386 | 387 | $p = new Parser(); 388 | 389 | $schema = $p->union([$p->string(), $p->int()]); 390 | 391 | $data = $schema->parse('42'); 392 | ``` 393 | 394 | ## Copyright 395 | 396 | 2025 Dominik Zogg 397 | 398 | [1]: https://packagist.org/packages/chubbyphp/chubbyphp-parsing 399 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chubbyphp/chubbyphp-parsing", 3 | "description": "Allows parsing data of various structures, meaning the population and validation of data into a defined structure. For example, converting an API request into a Data Transfer Object (DTO).", 4 | "keywords": [ 5 | "chubbyphp", 6 | "dto", 7 | "parsing", 8 | "population", 9 | "validation", 10 | "zod" 11 | ], 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Dominik Zogg", 16 | "email": "dominik.zogg@gmail.com" 17 | } 18 | ], 19 | "require": { 20 | "php": "^8.2" 21 | }, 22 | "require-dev": { 23 | "chubbyphp/chubbyphp-dev-helper": "dev-master", 24 | "infection/infection": "^0.29.12", 25 | "php-coveralls/php-coveralls": "^2.7.0", 26 | "phpstan/extension-installer": "^1.4.3", 27 | "phpstan/phpstan": "^2.1.6", 28 | "phpunit/phpunit": "^11.5.9", 29 | "respect/validation": "^2.4" 30 | }, 31 | "suggest": { 32 | "respect/validation": "If your interested in using the respect/validation, please install it with ^2.4" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "Chubbyphp\\Parsing\\": "src/" 37 | } 38 | }, 39 | "autoload-dev": { 40 | "psr-4": { 41 | "Chubbyphp\\Tests\\Parsing\\": "tests/" 42 | } 43 | }, 44 | "config": { 45 | "sort-packages": true, 46 | "allow-plugins": { 47 | "infection/extension-installer": true, 48 | "phpstan/extension-installer": true 49 | } 50 | }, 51 | "extra": { 52 | "branch-alias": { 53 | "dev-master": "1.4-dev" 54 | } 55 | }, 56 | "scripts": { 57 | "fix:cs": "mkdir -p build && PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix --cache-file=build/phpcs.cache", 58 | "test": [ 59 | "@test:lint", 60 | "@test:unit", 61 | "@test:integration", 62 | "@test:infection", 63 | "@test:static-analysis", 64 | "@test:cs" 65 | ], 66 | "test:cs": "mkdir -p build && PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix --dry-run --stop-on-violation --cache-file=build/phpcs.cache", 67 | "test:infection": "vendor/bin/infection --threads=$(nproc) --min-msi=100 --verbose --coverage=build/phpunit", 68 | "test:integration": "vendor/bin/phpunit --testsuite=Integration --cache-directory=build/phpunit", 69 | "test:lint": "mkdir -p build && find src tests -name '*.php' -print0 | xargs -0 -n1 -P$(nproc) php -l | tee build/phplint.log", 70 | "test:static-analysis": "mkdir -p build && bash -c 'vendor/bin/phpstan analyse src --no-progress --level=8 --error-format=junit | tee build/phpstan.junit.xml; if [ ${PIPESTATUS[0]} -ne \"0\" ]; then exit 1; fi'", 71 | "test:unit": "vendor/bin/phpunit --testsuite=Unit --coverage-text --coverage-clover=build/phpunit/clover.xml --coverage-html=build/phpunit/coverage-html --coverage-xml=build/phpunit/coverage-xml --log-junit=build/phpunit/junit.xml --cache-directory=build/phpunit" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Error.php: -------------------------------------------------------------------------------- 1 | $variables 11 | */ 12 | public function __construct(public string $code, public string $template, public array $variables) {} 13 | 14 | public function __toString() 15 | { 16 | $message = $this->template; 17 | foreach ($this->variables as $name => $value) { 18 | $encodedValue = json_encode($value); 19 | $message = str_replace( 20 | '{{'.$name.'}}', 21 | false !== $encodedValue ? $encodedValue : '', 22 | $message 23 | ); 24 | } 25 | 26 | return $message; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Parser.php: -------------------------------------------------------------------------------- 1 | $backedEnumClass 35 | */ 36 | public function backedEnum(string $backedEnumClass): BackedEnumSchema 37 | { 38 | return new BackedEnumSchema($backedEnumClass); 39 | } 40 | 41 | public function bool(): BoolSchema 42 | { 43 | return new BoolSchema(); 44 | } 45 | 46 | public function dateTime(): DateTimeSchema 47 | { 48 | return new DateTimeSchema(); 49 | } 50 | 51 | /** 52 | * @param array $objectSchemas 53 | */ 54 | public function discriminatedUnion(array $objectSchemas, string $discriminatorFieldName): DiscriminatedUnionSchema 55 | { 56 | return new DiscriminatedUnionSchema($objectSchemas, $discriminatorFieldName); 57 | } 58 | 59 | public function float(): FloatSchema 60 | { 61 | return new FloatSchema(); 62 | } 63 | 64 | public function int(): IntSchema 65 | { 66 | return new IntSchema(); 67 | } 68 | 69 | /** 70 | * @param \Closure(): SchemaInterface $lazy 71 | */ 72 | public function lazy(\Closure $lazy): SchemaInterface 73 | { 74 | return new LazySchema($lazy); 75 | } 76 | 77 | public function literal(bool|float|int|string $literal): LiteralSchema 78 | { 79 | return new LiteralSchema($literal); 80 | } 81 | 82 | /** 83 | * @param array $fieldNameToSchema 84 | * @param class-string $classname 85 | */ 86 | public function object(array $fieldNameToSchema, string $classname = \stdClass::class): ObjectSchema 87 | { 88 | return new ObjectSchema($fieldNameToSchema, $classname); 89 | } 90 | 91 | public function record(SchemaInterface $fieldSchema): RecordSchema 92 | { 93 | return new RecordSchema($fieldSchema); 94 | } 95 | 96 | public function string(): StringSchema 97 | { 98 | return new StringSchema(); 99 | } 100 | 101 | /** 102 | * @param array $schemas 103 | */ 104 | public function tuple(array $schemas): TupleSchema 105 | { 106 | return new TupleSchema($schemas); 107 | } 108 | 109 | /** 110 | * @param array $schemas 111 | */ 112 | public function union(array $schemas): UnionSchema 113 | { 114 | return new UnionSchema($schemas); 115 | } 116 | 117 | public function respectValidation(Validatable $validatable): RespectValidationSchema 118 | { 119 | return new RespectValidationSchema($validatable); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/ParserErrorException.php: -------------------------------------------------------------------------------- 1 | } 9 | */ 10 | final class ParserErrorException extends \RuntimeException 11 | { 12 | private array $errors = []; 13 | 14 | public function __construct(?Error $error = null, null|int|string $key = null) 15 | { 16 | $this->message = new ParserErrorExceptionToString($this); 17 | 18 | if ($error) { 19 | $this->addError($error, $key); 20 | } 21 | } 22 | 23 | public function __toString(): string 24 | { 25 | return self::class; 26 | } 27 | 28 | public function addParserErrorException(self $parserErrorException, null|int|string $key = null): self 29 | { 30 | if (null !== $key) { 31 | $this->errors = $this->mergeErrors([$key => $parserErrorException->getErrors()], $this->errors); 32 | 33 | return $this; 34 | } 35 | 36 | $this->errors = $this->mergeErrors($parserErrorException->getErrors(), $this->errors); 37 | 38 | return $this; 39 | } 40 | 41 | public function addError(Error $error, null|int|string $key = null): self 42 | { 43 | if (null !== $key) { 44 | $this->errors = $this->mergeErrors([$key => [$error]], $this->errors); 45 | 46 | return $this; 47 | } 48 | 49 | $this->errors = $this->mergeErrors([$error], $this->errors); 50 | 51 | return $this; 52 | } 53 | 54 | public function getErrors(): array 55 | { 56 | return $this->errors; 57 | } 58 | 59 | public function hasError(): bool 60 | { 61 | return 0 !== \count($this->errors); 62 | } 63 | 64 | /** 65 | * @return array 66 | */ 67 | public function getApiProblemErrorMessages(): array 68 | { 69 | return $this->flatErrorsToApiProblemMessages($this->errors); 70 | } 71 | 72 | private function mergeErrors(array $errors, array $mergedErrors): array 73 | { 74 | foreach ($errors as $key => $error) { 75 | if ($error instanceof Error) { 76 | $mergedErrors[] = $error; 77 | } else { 78 | $mergedErrors[$key] = $this->mergeErrors($error, $mergedErrors[$key] ?? []); 79 | } 80 | } 81 | 82 | return $mergedErrors; 83 | } 84 | 85 | /** 86 | * @return array 87 | */ 88 | private function flatErrorsToApiProblemMessages(array $errors, string $path = ''): array 89 | { 90 | /** @var array */ 91 | $errorsToApiProblemMessages = []; 92 | 93 | foreach ($errors as $key => $error) { 94 | if ($error instanceof Error) { 95 | $errorsToApiProblemMessages[] = [ 96 | 'name' => $path, 97 | 'reason' => (string) $error, 98 | 'details' => [ 99 | '_template' => $error->template, 100 | ...$error->variables, 101 | ], 102 | ]; 103 | } else { 104 | $errorsToApiProblemMessages = array_merge( 105 | $errorsToApiProblemMessages, 106 | $this->flatErrorsToApiProblemMessages( 107 | $error, 108 | '' === $path ? $key : $path.'['.$key.']' 109 | ) 110 | ); 111 | } 112 | } 113 | 114 | return $errorsToApiProblemMessages; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/ParserErrorExceptionToString.php: -------------------------------------------------------------------------------- 1 | */ 17 | $lines = []; 18 | 19 | foreach ($this->e->getApiProblemErrorMessages() as $apiProblemErrorMessage) { 20 | $lines[] = "{$apiProblemErrorMessage['name']}: {$apiProblemErrorMessage['reason']}"; 21 | } 22 | 23 | return implode(PHP_EOL, $lines); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/ParserInterface.php: -------------------------------------------------------------------------------- 1 | $backedEnumClass 29 | */ 30 | public function backedEnum(string $backedEnumClass): BackedEnumSchema; 31 | 32 | public function bool(): BoolSchema; 33 | 34 | public function dateTime(): DateTimeSchema; 35 | 36 | /** 37 | * @param array $objectSchemas 38 | */ 39 | public function discriminatedUnion(array $objectSchemas, string $discriminatorFieldName): DiscriminatedUnionSchema; 40 | 41 | public function float(): FloatSchema; 42 | 43 | public function int(): IntSchema; 44 | 45 | /** 46 | * @param \Closure(): SchemaInterface $lazy 47 | */ 48 | public function lazy(\Closure $lazy): SchemaInterface; 49 | 50 | public function literal(bool|float|int|string $literal): LiteralSchema; 51 | 52 | /** 53 | * @param array $fieldNameToSchema 54 | * @param class-string $classname 55 | */ 56 | public function object(array $fieldNameToSchema, string $classname = \stdClass::class): ObjectSchema; 57 | 58 | public function record(SchemaInterface $fieldSchema): RecordSchema; 59 | 60 | public function string(): StringSchema; 61 | 62 | /** 63 | * @param array $schemas 64 | */ 65 | public function tuple(array $schemas): TupleSchema; 66 | 67 | /** 68 | * @param array $schemas 69 | */ 70 | public function union(array $schemas): UnionSchema; 71 | } 72 | -------------------------------------------------------------------------------- /src/Result.php: -------------------------------------------------------------------------------- 1 | success = null === $exception; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Schema/AbstractSchema.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | protected array $preParses = []; 18 | 19 | /** 20 | * @var array<\Closure(mixed): mixed> 21 | */ 22 | protected array $postParses = []; 23 | 24 | /** 25 | * @var \Closure(mixed, ParserErrorException): mixed 26 | */ 27 | protected ?\Closure $catch = null; 28 | 29 | final public function nullable(bool $nullable = true): static 30 | { 31 | $clone = clone $this; 32 | 33 | $clone->nullable = $nullable; 34 | 35 | return $clone; 36 | } 37 | 38 | /** 39 | * @param \Closure(mixed $input): mixed $preParse 40 | */ 41 | final public function preParse(\Closure $preParse): static 42 | { 43 | $clone = clone $this; 44 | 45 | $clone->preParses[] = $preParse; 46 | 47 | return $clone; 48 | } 49 | 50 | /** 51 | * @param \Closure(mixed $input): mixed $postParse 52 | */ 53 | final public function postParse(\Closure $postParse): static 54 | { 55 | $clone = clone $this; 56 | 57 | $clone->postParses[] = $postParse; 58 | 59 | return $clone; 60 | } 61 | 62 | final public function safeParse(mixed $input): Result 63 | { 64 | try { 65 | return new Result($this->parse($input), null); 66 | } catch (ParserErrorException $parserErrorException) { 67 | return new Result(null, $parserErrorException); 68 | } 69 | } 70 | 71 | /** 72 | * @param \Closure(mixed $input, ParserErrorException $parserErrorException): mixed $catch 73 | */ 74 | final public function catch(\Closure $catch): static 75 | { 76 | $clone = clone $this; 77 | 78 | $clone->catch = $catch; 79 | 80 | return $clone; 81 | } 82 | 83 | final public function default(mixed $default): static 84 | { 85 | return $this->preParse(static fn (mixed $input) => $input ?? $default); 86 | } 87 | 88 | final protected function dispatchPreParses(mixed $data): mixed 89 | { 90 | return array_reduce( 91 | $this->preParses, 92 | static fn (mixed $currentData, \Closure $preParse) => $preParse($currentData), 93 | $data 94 | ); 95 | } 96 | 97 | final protected function dispatchPostParses(mixed $data): mixed 98 | { 99 | return array_reduce( 100 | $this->postParses, 101 | static fn (mixed $currentData, \Closure $postParse) => $postParse($currentData), 102 | $data 103 | ); 104 | } 105 | 106 | final protected function getDataType(mixed $input): string 107 | { 108 | return \is_object($input) ? $input::class : \gettype($input); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Schema/ArraySchema.php: -------------------------------------------------------------------------------- 1 | dispatchPreParses($input); 33 | 34 | if (null === $input && $this->nullable) { 35 | return null; 36 | } 37 | 38 | if (!\is_array($input)) { 39 | throw new ParserErrorException( 40 | new Error( 41 | self::ERROR_TYPE_CODE, 42 | self::ERROR_TYPE_TEMPLATE, 43 | ['given' => $this->getDataType($input)] 44 | ) 45 | ); 46 | } 47 | 48 | $array = []; 49 | 50 | $childrenParserErrorException = new ParserErrorException(); 51 | 52 | foreach ($input as $i => $item) { 53 | try { 54 | $array[$i] = $this->itemSchema->parse($item); 55 | } catch (ParserErrorException $childParserErrorException) { 56 | $childrenParserErrorException->addParserErrorException($childParserErrorException, $i); 57 | } 58 | } 59 | 60 | if ($childrenParserErrorException->hasError()) { 61 | throw $childrenParserErrorException; 62 | } 63 | 64 | return $this->dispatchPostParses($array); 65 | } catch (ParserErrorException $parserErrorException) { 66 | if ($this->catch) { 67 | return ($this->catch)($input, $parserErrorException); 68 | } 69 | 70 | throw $parserErrorException; 71 | } 72 | } 73 | 74 | public function length(int $length): static 75 | { 76 | return $this->postParse(static function (array $array) use ($length) { 77 | $arrayLength = \count($array); 78 | 79 | if ($arrayLength !== $length) { 80 | throw new ParserErrorException( 81 | new Error( 82 | self::ERROR_LENGTH_CODE, 83 | self::ERROR_LENGTH_TEMPLATE, 84 | ['length' => $length, 'given' => $arrayLength] 85 | ) 86 | ); 87 | } 88 | 89 | return $array; 90 | }); 91 | } 92 | 93 | public function minLength(int $minLength): static 94 | { 95 | return $this->postParse(static function (array $array) use ($minLength) { 96 | $arrayLength = \count($array); 97 | 98 | if ($arrayLength < $minLength) { 99 | throw new ParserErrorException( 100 | new Error( 101 | self::ERROR_MIN_LENGTH_CODE, 102 | self::ERROR_MIN_LENGTH_TEMPLATE, 103 | ['minLength' => $minLength, 'given' => $arrayLength] 104 | ) 105 | ); 106 | } 107 | 108 | return $array; 109 | }); 110 | } 111 | 112 | public function maxLength(int $maxLength): static 113 | { 114 | return $this->postParse(static function (array $array) use ($maxLength) { 115 | $arrayLength = \count($array); 116 | 117 | if ($arrayLength > $maxLength) { 118 | throw new ParserErrorException( 119 | new Error( 120 | self::ERROR_MAX_LENGTH_CODE, 121 | self::ERROR_MAX_LENGTH_TEMPLATE, 122 | ['maxLength' => $maxLength, 'given' => $arrayLength] 123 | ) 124 | ); 125 | } 126 | 127 | return $array; 128 | }); 129 | } 130 | 131 | public function includes(mixed $includes, bool $strict = true): static 132 | { 133 | return $this->postParse(static function (array $array) use ($includes, $strict) { 134 | if (!\in_array($includes, $array, $strict)) { 135 | throw new ParserErrorException( 136 | new Error( 137 | self::ERROR_INCLUDES_CODE, 138 | self::ERROR_INCLUDES_TEMPLATE, 139 | ['includes' => $includes, 'given' => $array] 140 | ) 141 | ); 142 | } 143 | 144 | return $array; 145 | }); 146 | } 147 | 148 | /** 149 | * @param \Closure(mixed $value, int $index): mixed $filter 150 | */ 151 | public function filter(\Closure $filter): static 152 | { 153 | return $this->postParse( 154 | static fn (array $array) => array_values(array_filter($array, $filter, ARRAY_FILTER_USE_BOTH)) 155 | ); 156 | } 157 | 158 | /** 159 | * @param \Closure(mixed $value): mixed $map 160 | */ 161 | public function map(\Closure $map): static 162 | { 163 | return $this->postParse(static fn (array $array) => array_map($map, $array)); 164 | } 165 | 166 | /** 167 | * @param null|\Closure(mixed $a, mixed $b): mixed $compare 168 | */ 169 | public function sort(?\Closure $compare = null): static 170 | { 171 | return $this->postParse(static function (array $array) use ($compare) { 172 | if ($compare) { 173 | usort($array, $compare); 174 | } else { 175 | sort($array); 176 | } 177 | 178 | return $array; 179 | }); 180 | } 181 | 182 | /** 183 | * @param \Closure(mixed $existing, mixed $current): mixed $reduce 184 | */ 185 | public function reduce(\Closure $reduce, mixed $initial = null): static 186 | { 187 | return $this->postParse(static fn (array $array) => array_reduce($array, $reduce, $initial)); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/Schema/BackedEnumSchema.php: -------------------------------------------------------------------------------- 1 | $backedEnumClass 22 | */ 23 | public function __construct(string $backedEnumClass) 24 | { 25 | if (!enum_exists($backedEnumClass)) { 26 | throw new \InvalidArgumentException( 27 | \sprintf( 28 | 'Argument #1 ($backedEnum) must be of type \BackedEnum::class, %s given', 29 | $this->getDataType($backedEnumClass) 30 | ) 31 | ); 32 | } 33 | 34 | $cases = $backedEnumClass::cases(); 35 | 36 | $backedEnum = array_shift($cases); 37 | 38 | if (!$backedEnum instanceof \BackedEnum) { 39 | throw new \InvalidArgumentException( 40 | \sprintf( 41 | 'Argument #1 ($backedEnum) must be of type \BackedEnum::class, %s given', 42 | $this->getDataType($backedEnumClass) 43 | ) 44 | ); 45 | } 46 | 47 | $this->backedEnum = $backedEnum; 48 | } 49 | 50 | public function parse(mixed $input): mixed 51 | { 52 | try { 53 | $input = $this->dispatchPreParses($input); 54 | 55 | if (null === $input && $this->nullable) { 56 | return null; 57 | } 58 | 59 | if (!\is_int($input) && !\is_string($input)) { 60 | throw new ParserErrorException( 61 | new Error( 62 | self::ERROR_TYPE_CODE, 63 | self::ERROR_TYPE_TEMPLATE, 64 | ['given' => $this->getDataType($input)] 65 | ) 66 | ); 67 | } 68 | 69 | $output = ($this->backedEnum)::tryFrom($input); 70 | 71 | if (null === $output) { 72 | throw new ParserErrorException( 73 | new Error( 74 | self::ERROR_VALUE_CODE, 75 | self::ERROR_VALUE_TEMPLATE, 76 | [ 77 | 'cases' => $this->casesToCasesValues($this->backedEnum), 78 | 'given' => $input, 79 | ] 80 | ) 81 | ); 82 | } 83 | 84 | return $this->dispatchPostParses($output); 85 | } catch (ParserErrorException $parserErrorException) { 86 | if ($this->catch) { 87 | return ($this->catch)($input, $parserErrorException); 88 | } 89 | 90 | throw $parserErrorException; 91 | } 92 | } 93 | 94 | public function toInt(): IntSchema 95 | { 96 | return (new IntSchema())->preParse(function ($input) { 97 | /** @var null|\BackedEnum $input */ 98 | $input = $this->parse($input); 99 | 100 | return null !== $input ? $input->value : null; 101 | })->nullable($this->nullable); 102 | } 103 | 104 | public function toString(): StringSchema 105 | { 106 | return (new StringSchema())->preParse(function ($input) { 107 | /** @var null|\BackedEnum $input */ 108 | $input = $this->parse($input); 109 | 110 | return null !== $input ? $input->value : null; 111 | })->nullable($this->nullable); 112 | } 113 | 114 | /** 115 | * @return array 116 | */ 117 | private function casesToCasesValues(\BackedEnum $enum): array 118 | { 119 | $cases = []; 120 | foreach ($enum::cases() as $i => $case) { 121 | $cases[$i] = $case->value; 122 | } 123 | 124 | return $cases; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Schema/BoolSchema.php: -------------------------------------------------------------------------------- 1 | dispatchPreParses($input); 19 | 20 | if (null === $input && $this->nullable) { 21 | return null; 22 | } 23 | 24 | if (!\is_bool($input)) { 25 | throw new ParserErrorException( 26 | new Error( 27 | self::ERROR_TYPE_CODE, 28 | self::ERROR_TYPE_TEMPLATE, 29 | ['given' => $this->getDataType($input)] 30 | ) 31 | ); 32 | } 33 | 34 | return $this->dispatchPostParses($input); 35 | } catch (ParserErrorException $parserErrorException) { 36 | if ($this->catch) { 37 | return ($this->catch)($input, $parserErrorException); 38 | } 39 | 40 | throw $parserErrorException; 41 | } 42 | } 43 | 44 | public function toFloat(): FloatSchema 45 | { 46 | return (new FloatSchema())->preParse(function ($input) { 47 | /** @var null|bool $input */ 48 | $input = $this->parse($input); 49 | 50 | return null !== $input ? (float) $input : null; 51 | })->nullable($this->nullable); 52 | } 53 | 54 | public function toInt(): IntSchema 55 | { 56 | return (new IntSchema())->preParse(function ($input) { 57 | /** @var null|bool $input */ 58 | $input = $this->parse($input); 59 | 60 | return null !== $input ? (int) $input : null; 61 | })->nullable($this->nullable); 62 | } 63 | 64 | public function toString(): StringSchema 65 | { 66 | return (new StringSchema())->preParse(function ($input) { 67 | /** @var null|bool $input */ 68 | $input = $this->parse($input); 69 | 70 | return null !== $input ? (string) $input : null; 71 | })->nullable($this->nullable); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Schema/DateTimeSchema.php: -------------------------------------------------------------------------------- 1 | dispatchPreParses($input); 25 | 26 | if (null === $input && $this->nullable) { 27 | return null; 28 | } 29 | 30 | if (!$input instanceof \DateTimeInterface) { 31 | throw new ParserErrorException( 32 | new Error( 33 | self::ERROR_TYPE_CODE, 34 | self::ERROR_TYPE_TEMPLATE, 35 | ['given' => $this->getDataType($input)] 36 | ) 37 | ); 38 | } 39 | 40 | return $this->dispatchPostParses($input); 41 | } catch (ParserErrorException $parserErrorException) { 42 | if ($this->catch) { 43 | return ($this->catch)($input, $parserErrorException); 44 | } 45 | 46 | throw $parserErrorException; 47 | } 48 | } 49 | 50 | public function from(\DateTimeImmutable $from): static 51 | { 52 | return $this->postParse(static function (\DateTimeImmutable $datetime) use ($from) { 53 | if ($datetime < $from) { 54 | throw new ParserErrorException( 55 | new Error( 56 | self::ERROR_FROM_CODE, 57 | self::ERROR_FROM_TEMPLATE, 58 | ['from' => $from->format('c'), 'given' => $datetime->format('c')] 59 | ) 60 | ); 61 | } 62 | 63 | return $datetime; 64 | }); 65 | } 66 | 67 | public function to(\DateTimeImmutable $to): static 68 | { 69 | return $this->postParse(static function (\DateTimeImmutable $datetime) use ($to) { 70 | if ($datetime > $to) { 71 | throw new ParserErrorException( 72 | new Error( 73 | self::ERROR_TO_CODE, 74 | self::ERROR_TO_TEMPLATE, 75 | ['to' => $to->format('c'), 'given' => $datetime->format('c')] 76 | ) 77 | ); 78 | } 79 | 80 | return $datetime; 81 | }); 82 | } 83 | 84 | public function toInt(): IntSchema 85 | { 86 | return (new IntSchema())->preParse(function ($input) { 87 | /** @var null|\DateTimeInterface $input */ 88 | $input = $this->parse($input); 89 | 90 | return null !== $input ? $input->getTimestamp() : null; 91 | })->nullable($this->nullable); 92 | } 93 | 94 | public function toString(): StringSchema 95 | { 96 | return (new StringSchema())->preParse(function ($input) { 97 | /** @var null|\DateTimeInterface $input */ 98 | $input = $this->parse($input); 99 | 100 | return null !== $input ? $input->format('c') : null; 101 | })->nullable($this->nullable); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Schema/DiscriminatedUnionSchema.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | private array $objectSchemas; 23 | 24 | /** 25 | * @param array $objectSchemas 26 | */ 27 | public function __construct(array $objectSchemas, private string $discriminatorFieldName) 28 | { 29 | foreach ($objectSchemas as $i => $objectSchema) { 30 | if (!$objectSchema instanceof ObjectSchemaInterface) { 31 | throw new \InvalidArgumentException( 32 | \sprintf( 33 | 'Argument #1 value of #%s ($objectSchemas) must be of type %s, %s given', 34 | $i, 35 | ObjectSchemaInterface::class, 36 | $this->getDataType($objectSchema) 37 | ) 38 | ); 39 | } 40 | 41 | $discriminatorFieldSchema = $objectSchema->getFieldSchema($discriminatorFieldName); 42 | 43 | if (null === $discriminatorFieldSchema) { 44 | throw new \InvalidArgumentException( 45 | \sprintf( 46 | 'Argument #1 value of #%s #%s ($objectSchemas) must contain %s', 47 | $i, 48 | $discriminatorFieldName, 49 | SchemaInterface::class, 50 | ) 51 | ); 52 | } 53 | } 54 | 55 | $this->objectSchemas = $objectSchemas; 56 | } 57 | 58 | public function parse(mixed $input): mixed 59 | { 60 | if ($input instanceof \stdClass || $input instanceof \Traversable) { 61 | $input = (array) $input; 62 | } 63 | 64 | if ($input instanceof \JsonSerializable) { 65 | $input = $input->jsonSerialize(); 66 | } 67 | 68 | try { 69 | $input = $this->dispatchPreParses($input); 70 | 71 | if (null === $input && $this->nullable) { 72 | return null; 73 | } 74 | 75 | if (!\is_array($input)) { 76 | throw new ParserErrorException( 77 | new Error( 78 | self::ERROR_TYPE_CODE, 79 | self::ERROR_TYPE_TEMPLATE, 80 | ['given' => $this->getDataType($input)] 81 | ) 82 | ); 83 | } 84 | 85 | if (!isset($input[$this->discriminatorFieldName])) { 86 | throw new ParserErrorException( 87 | new Error( 88 | self::ERROR_DISCRIMINATOR_FIELD_CODE, 89 | self::ERROR_DISCRIMINATOR_FIELD_TEMPLATE, 90 | ['discriminatorFieldName' => $this->discriminatorFieldName] 91 | ) 92 | ); 93 | } 94 | 95 | $output = $this->parseObjectSchemas($input, $input[$this->discriminatorFieldName]); 96 | 97 | return $this->dispatchPostParses($output); 98 | } catch (ParserErrorException $parserErrorException) { 99 | if ($this->catch) { 100 | return ($this->catch)($input, $parserErrorException); 101 | } 102 | 103 | throw $parserErrorException; 104 | } 105 | } 106 | 107 | private function parseObjectSchemas(mixed $input, mixed $discriminator): mixed 108 | { 109 | $parserErrorException = new ParserErrorException(); 110 | 111 | foreach ($this->objectSchemas as $objectSchema) { 112 | /** @var SchemaInterface $discriminatorFieldSchema */ 113 | $discriminatorFieldSchema = $objectSchema->getFieldSchema($this->discriminatorFieldName); 114 | 115 | try { 116 | $discriminatorFieldSchema->parse($discriminator); 117 | } catch (ParserErrorException $childParserErrorException) { 118 | $parserErrorException->addParserErrorException($childParserErrorException); 119 | 120 | continue; 121 | } 122 | 123 | return $objectSchema->parse($input); 124 | } 125 | 126 | throw $parserErrorException; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/Schema/FloatSchema.php: -------------------------------------------------------------------------------- 1 | dispatchPreParses($input); 34 | 35 | if (null === $input && $this->nullable) { 36 | return null; 37 | } 38 | 39 | if (!\is_float($input)) { 40 | throw new ParserErrorException( 41 | new Error( 42 | self::ERROR_TYPE_CODE, 43 | self::ERROR_TYPE_TEMPLATE, 44 | ['given' => $this->getDataType($input)] 45 | ) 46 | ); 47 | } 48 | 49 | return $this->dispatchPostParses($input); 50 | } catch (ParserErrorException $parserErrorException) { 51 | if ($this->catch) { 52 | return ($this->catch)($input, $parserErrorException); 53 | } 54 | 55 | throw $parserErrorException; 56 | } 57 | } 58 | 59 | public function gt(float $gt): static 60 | { 61 | return $this->postParse(static function (float $float) use ($gt) { 62 | if ($float <= $gt) { 63 | throw new ParserErrorException( 64 | new Error( 65 | self::ERROR_GT_CODE, 66 | self::ERROR_GT_TEMPLATE, 67 | ['gt' => $gt, 'given' => $float] 68 | ) 69 | ); 70 | } 71 | 72 | return $float; 73 | }); 74 | } 75 | 76 | public function gte(float $gte): static 77 | { 78 | return $this->postParse(static function (float $float) use ($gte) { 79 | if ($float < $gte) { 80 | throw new ParserErrorException( 81 | new Error( 82 | self::ERROR_GTE_CODE, 83 | self::ERROR_GTE_TEMPLATE, 84 | ['gte' => $gte, 'given' => $float] 85 | ) 86 | ); 87 | } 88 | 89 | return $float; 90 | }); 91 | } 92 | 93 | public function lt(float $lt): static 94 | { 95 | return $this->postParse(static function (float $float) use ($lt) { 96 | if ($float >= $lt) { 97 | throw new ParserErrorException( 98 | new Error( 99 | self::ERROR_LT_CODE, 100 | self::ERROR_LT_TEMPLATE, 101 | ['lt' => $lt, 'given' => $float] 102 | ) 103 | ); 104 | } 105 | 106 | return $float; 107 | }); 108 | } 109 | 110 | public function lte(float $lte): static 111 | { 112 | return $this->postParse(static function (float $float) use ($lte) { 113 | if ($float > $lte) { 114 | throw new ParserErrorException( 115 | new Error( 116 | self::ERROR_LTE_CODE, 117 | self::ERROR_LTE_TEMPLATE, 118 | ['lte' => $lte, 'given' => $float] 119 | ) 120 | ); 121 | } 122 | 123 | return $float; 124 | }); 125 | } 126 | 127 | public function positive(): static 128 | { 129 | return $this->gt(0.0); 130 | } 131 | 132 | public function nonNegative(): static 133 | { 134 | return $this->gte(0.0); 135 | } 136 | 137 | public function negative(): static 138 | { 139 | return $this->lt(0.0); 140 | } 141 | 142 | public function nonPositive(): static 143 | { 144 | return $this->lte(0.0); 145 | } 146 | 147 | public function toInt(): IntSchema 148 | { 149 | return (new IntSchema())->preParse(function ($input) { 150 | /** @var null|float $input */ 151 | $input = $this->parse($input); 152 | 153 | if (null === $input) { 154 | return null; 155 | } 156 | 157 | $intInput = (int) $input; 158 | 159 | if ((float) $intInput !== $input) { 160 | throw new ParserErrorException( 161 | new Error( 162 | self::ERROR_INT_CODE, 163 | self::ERROR_INT_TEMPLATE, 164 | ['given' => $input] 165 | ) 166 | ); 167 | } 168 | 169 | return $intInput; 170 | })->nullable($this->nullable); 171 | } 172 | 173 | public function toString(): StringSchema 174 | { 175 | return (new StringSchema())->preParse(function ($input) { 176 | /** @var null|float $input */ 177 | $input = $this->parse($input); 178 | 179 | return null !== $input ? (string) $input : null; 180 | })->nullable($this->nullable); 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/Schema/IntSchema.php: -------------------------------------------------------------------------------- 1 | dispatchPreParses($input); 31 | 32 | if (null === $input && $this->nullable) { 33 | return null; 34 | } 35 | 36 | if (!\is_int($input)) { 37 | throw new ParserErrorException( 38 | new Error( 39 | self::ERROR_TYPE_CODE, 40 | self::ERROR_TYPE_TEMPLATE, 41 | ['given' => $this->getDataType($input)] 42 | ) 43 | ); 44 | } 45 | 46 | return $this->dispatchPostParses($input); 47 | } catch (ParserErrorException $parserErrorException) { 48 | if ($this->catch) { 49 | return ($this->catch)($input, $parserErrorException); 50 | } 51 | 52 | throw $parserErrorException; 53 | } 54 | } 55 | 56 | public function gt(int $gt): static 57 | { 58 | return $this->postParse(static function (int $int) use ($gt) { 59 | if ($int <= $gt) { 60 | throw new ParserErrorException( 61 | new Error( 62 | self::ERROR_GT_CODE, 63 | self::ERROR_GT_TEMPLATE, 64 | ['gt' => $gt, 'given' => $int] 65 | ) 66 | ); 67 | } 68 | 69 | return $int; 70 | }); 71 | } 72 | 73 | public function gte(int $gte): static 74 | { 75 | return $this->postParse(static function (int $int) use ($gte) { 76 | if ($int < $gte) { 77 | throw new ParserErrorException( 78 | new Error( 79 | self::ERROR_GTE_CODE, 80 | self::ERROR_GTE_TEMPLATE, 81 | ['gte' => $gte, 'given' => $int] 82 | ) 83 | ); 84 | } 85 | 86 | return $int; 87 | }); 88 | } 89 | 90 | public function lt(int $lt): static 91 | { 92 | return $this->postParse(static function (int $int) use ($lt) { 93 | if ($int >= $lt) { 94 | throw new ParserErrorException( 95 | new Error( 96 | self::ERROR_LT_CODE, 97 | self::ERROR_LT_TEMPLATE, 98 | ['lt' => $lt, 'given' => $int] 99 | ) 100 | ); 101 | } 102 | 103 | return $int; 104 | }); 105 | } 106 | 107 | public function lte(int $lte): static 108 | { 109 | return $this->postParse(static function (int $int) use ($lte) { 110 | if ($int > $lte) { 111 | throw new ParserErrorException( 112 | new Error( 113 | self::ERROR_LTE_CODE, 114 | self::ERROR_LTE_TEMPLATE, 115 | ['lte' => $lte, 'given' => $int] 116 | ) 117 | ); 118 | } 119 | 120 | return $int; 121 | }); 122 | } 123 | 124 | public function positive(): static 125 | { 126 | return $this->gt(0); 127 | } 128 | 129 | public function nonNegative(): static 130 | { 131 | return $this->gte(0); 132 | } 133 | 134 | public function negative(): static 135 | { 136 | return $this->lt(0); 137 | } 138 | 139 | public function nonPositive(): static 140 | { 141 | return $this->lte(0); 142 | } 143 | 144 | public function toFloat(): FloatSchema 145 | { 146 | return (new FloatSchema())->preParse(function ($input) { 147 | /** @var null|int $input */ 148 | $input = $this->parse($input); 149 | 150 | return null !== $input ? (float) $input : null; 151 | })->nullable($this->nullable); 152 | } 153 | 154 | public function toString(): StringSchema 155 | { 156 | return (new StringSchema())->preParse(function ($input) { 157 | /** @var null|int $input */ 158 | $input = $this->parse($input); 159 | 160 | return null !== $input ? (string) $input : null; 161 | })->nullable($this->nullable); 162 | } 163 | 164 | public function toDateTime(): DateTimeSchema 165 | { 166 | return (new DateTimeSchema())->preParse(function ($input) { 167 | /** @var null|int $input */ 168 | $input = $this->parse($input); 169 | 170 | return null !== $input ? new \DateTimeImmutable('@'.$input) : null; 171 | })->nullable($this->nullable); 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/Schema/LazySchema.php: -------------------------------------------------------------------------------- 1 | } 12 | */ 13 | final class LazySchema implements SchemaInterface 14 | { 15 | private ?SchemaInterface $schema = null; 16 | 17 | /** 18 | * @param \Closure(): SchemaInterface $lazy 19 | */ 20 | public function __construct(private \Closure $lazy) {} 21 | 22 | public function nullable(bool $nullable = true): static 23 | { 24 | throw new \BadMethodCallException( 25 | \sprintf( 26 | 'LazySchema does not support any modification, "nullable" called with %s.', 27 | $nullable ? 'true' : 'false' 28 | ) 29 | ); 30 | } 31 | 32 | /** 33 | * @param \Closure(mixed $input): mixed $preParse 34 | */ 35 | public function preParse(\Closure $preParse): static 36 | { 37 | throw new \BadMethodCallException('LazySchema does not support any modification, "preParse" called.'); 38 | } 39 | 40 | /** 41 | * @param \Closure(mixed $input): mixed $postParse 42 | */ 43 | public function postParse(\Closure $postParse): static 44 | { 45 | throw new \BadMethodCallException('LazySchema does not support any modification, "postParse" called.'); 46 | } 47 | 48 | /** 49 | * @param \Closure(mixed $input, ParserErrorException $parserErrorException): mixed $catch 50 | */ 51 | public function catch(\Closure $catch): static 52 | { 53 | throw new \BadMethodCallException('LazySchema does not support any modification, "catch" called.'); 54 | } 55 | 56 | public function parse(mixed $input): mixed 57 | { 58 | $schema = $this->resolveSchema(); 59 | 60 | return $schema->parse($input); 61 | } 62 | 63 | public function safeParse(mixed $input): Result 64 | { 65 | $schema = $this->resolveSchema(); 66 | 67 | return $schema->safeParse($input); 68 | } 69 | 70 | private function resolveSchema(): SchemaInterface 71 | { 72 | if (!$this->schema) { 73 | $this->schema = ($this->lazy)(); 74 | } 75 | 76 | return $this->schema; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Schema/LiteralSchema.php: -------------------------------------------------------------------------------- 1 | dispatchPreParses($input); 24 | 25 | if (null === $input && $this->nullable) { 26 | return null; 27 | } 28 | 29 | if (!\is_bool($input) && !\is_float($input) && !\is_int($input) && !\is_string($input)) { 30 | throw new ParserErrorException( 31 | new Error( 32 | self::ERROR_TYPE_CODE, 33 | self::ERROR_TYPE_TEMPLATE, 34 | ['given' => $this->getDataType($input)] 35 | ) 36 | ); 37 | } 38 | 39 | if ($input !== $this->literal) { 40 | throw new ParserErrorException( 41 | new Error( 42 | self::ERROR_EQUALS_CODE, 43 | self::ERROR_EQUALS_TEMPLATE, 44 | ['expected' => $this->literal, 'given' => $input] 45 | ) 46 | ); 47 | } 48 | 49 | return $this->dispatchPostParses($input); 50 | } catch (ParserErrorException $parserErrorException) { 51 | if ($this->catch) { 52 | return ($this->catch)($input, $parserErrorException); 53 | } 54 | 55 | throw $parserErrorException; 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Schema/ObjectSchema.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | private array $fieldToSchema; 22 | 23 | /** 24 | * @var null|array 25 | */ 26 | private ?array $strict = null; 27 | 28 | /** 29 | * @var null|array 30 | */ 31 | private ?array $optional = null; 32 | 33 | /** 34 | * @param array $fieldToSchema 35 | * @param class-string $classname 36 | */ 37 | public function __construct(array $fieldToSchema, private string $classname = \stdClass::class) 38 | { 39 | foreach ($fieldToSchema as $fieldName => $fieldSchema) { 40 | if (!\is_string($fieldName)) { 41 | throw new \InvalidArgumentException( 42 | \sprintf( 43 | 'Argument #1 name #%s ($fieldToSchema) must be of type string, %s given', 44 | $fieldName, 45 | $this->getDataType($fieldName) 46 | ) 47 | ); 48 | } 49 | 50 | if (!$fieldSchema instanceof SchemaInterface) { 51 | throw new \InvalidArgumentException( 52 | \sprintf( 53 | 'Argument #1 value of #%s ($fieldToSchema) must be of type %s, %s given', 54 | $fieldName, 55 | SchemaInterface::class, 56 | $this->getDataType($fieldSchema) 57 | ) 58 | ); 59 | } 60 | } 61 | 62 | $this->fieldToSchema = $fieldToSchema; 63 | } 64 | 65 | public function parse(mixed $input): mixed 66 | { 67 | if ($input instanceof \stdClass || $input instanceof \Traversable) { 68 | $input = (array) $input; 69 | } 70 | 71 | if ($input instanceof \JsonSerializable) { 72 | $input = $input->jsonSerialize(); 73 | } 74 | 75 | try { 76 | $input = $this->dispatchPreParses($input); 77 | 78 | if (null === $input && $this->nullable) { 79 | return null; 80 | } 81 | 82 | if (!\is_array($input)) { 83 | throw new ParserErrorException( 84 | new Error( 85 | self::ERROR_TYPE_CODE, 86 | self::ERROR_TYPE_TEMPLATE, 87 | ['given' => $this->getDataType($input)] 88 | ) 89 | ); 90 | } 91 | 92 | $output = new $this->classname(); 93 | 94 | $childrenParserErrorException = new ParserErrorException(); 95 | 96 | $this->unknownFields($input, $childrenParserErrorException); 97 | 98 | $this->parseFields($input, $output, $childrenParserErrorException); 99 | 100 | if ($childrenParserErrorException->hasError()) { 101 | throw $childrenParserErrorException; 102 | } 103 | 104 | return $this->dispatchPostParses($output); 105 | } catch (ParserErrorException $childrenParserErrorException) { 106 | if ($this->catch) { 107 | return ($this->catch)($input, $childrenParserErrorException); 108 | } 109 | 110 | throw $childrenParserErrorException; 111 | } 112 | } 113 | 114 | /** 115 | * @return array 116 | */ 117 | public function getFieldToSchema(): array 118 | { 119 | return $this->fieldToSchema; 120 | } 121 | 122 | public function getFieldSchema(string $field): ?SchemaInterface 123 | { 124 | return $this->fieldToSchema[$field] ?? null; 125 | } 126 | 127 | /** 128 | * @param array $optional 129 | */ 130 | public function optional(array $optional = []): static 131 | { 132 | $clone = clone $this; 133 | 134 | $clone->optional = $optional; 135 | 136 | return $clone; 137 | } 138 | 139 | /** 140 | * @param array $strict 141 | */ 142 | public function strict(array $strict = []): static 143 | { 144 | $clone = clone $this; 145 | 146 | $clone->strict = $strict; 147 | 148 | return $clone; 149 | } 150 | 151 | /** 152 | * @param array $input 153 | */ 154 | private function unknownFields(array $input, ParserErrorException $childrenParserErrorException): void 155 | { 156 | if (null === $this->strict) { 157 | return; 158 | } 159 | 160 | foreach (array_keys($input) as $fieldName) { 161 | if (!\in_array($fieldName, $this->strict, true) && !isset($this->fieldToSchema[$fieldName])) { 162 | $childrenParserErrorException->addError(new Error( 163 | self::ERROR_UNKNOWN_FIELD_CODE, 164 | self::ERROR_UNKNOWN_FIELD_TEMPLATE, 165 | ['fieldName' => $fieldName] 166 | ), $fieldName); 167 | } 168 | } 169 | } 170 | 171 | /** 172 | * @param array $input 173 | */ 174 | private function parseFields(array $input, object $object, ParserErrorException $childrenParserErrorException): void 175 | { 176 | foreach ($this->fieldToSchema as $fieldName => $fieldSchema) { 177 | try { 178 | if ( 179 | !\array_key_exists($fieldName, $input) 180 | && \is_array($this->optional) 181 | && \in_array($fieldName, $this->optional, true) 182 | ) { 183 | continue; 184 | } 185 | 186 | $object->{$fieldName} = $fieldSchema->parse($input[$fieldName] ?? null); 187 | } catch (ParserErrorException $childParserErrorException) { 188 | $childrenParserErrorException->addParserErrorException($childParserErrorException, $fieldName); 189 | } 190 | } 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/Schema/ObjectSchemaInterface.php: -------------------------------------------------------------------------------- 1 | $optional) 9 | */ 10 | interface ObjectSchemaInterface extends SchemaInterface 11 | { 12 | public function getFieldSchema(string $fieldName): ?SchemaInterface; 13 | 14 | /** 15 | * @param array $strict 16 | */ 17 | public function strict(array $strict = []): static; 18 | 19 | // /** 20 | // * @param array $optional 21 | // */ 22 | // public function optional(array $optional): static; 23 | } 24 | -------------------------------------------------------------------------------- /src/Schema/RecordSchema.php: -------------------------------------------------------------------------------- 1 | jsonSerialize(); 25 | } 26 | 27 | try { 28 | $input = $this->dispatchPreParses($input); 29 | 30 | if (null === $input && $this->nullable) { 31 | return null; 32 | } 33 | 34 | if (!\is_array($input)) { 35 | throw new ParserErrorException( 36 | new Error( 37 | self::ERROR_TYPE_CODE, 38 | self::ERROR_TYPE_TEMPLATE, 39 | ['given' => $this->getDataType($input)] 40 | ) 41 | ); 42 | } 43 | 44 | $output = []; 45 | 46 | $childrenParserErrorException = new ParserErrorException(); 47 | 48 | foreach ($input as $fieldName => $fieldValue) { 49 | try { 50 | $output[$fieldName] = $this->fieldSchema->parse($fieldValue); 51 | } catch (ParserErrorException $childParserErrorException) { 52 | $childrenParserErrorException->addParserErrorException($childParserErrorException, $fieldName); 53 | } 54 | } 55 | 56 | if ($childrenParserErrorException->hasError()) { 57 | throw $childrenParserErrorException; 58 | } 59 | 60 | return $this->dispatchPostParses($output); 61 | } catch (ParserErrorException $parserErrorException) { 62 | if ($this->catch) { 63 | return ($this->catch)($input, $parserErrorException); 64 | } 65 | 66 | throw $parserErrorException; 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Schema/RespectValidationSchema.php: -------------------------------------------------------------------------------- 1 | dispatchPreParses($input); 21 | 22 | if (null === $input && $this->nullable) { 23 | return null; 24 | } 25 | 26 | try { 27 | $this->validatable->assert($input); 28 | 29 | return $this->dispatchPostParses($input); 30 | } catch (ValidationException $e) { 31 | throw $this->convertException($e); 32 | } 33 | } catch (ParserErrorException $parserErrorException) { 34 | if ($this->catch) { 35 | return ($this->catch)($input, $parserErrorException); 36 | } 37 | 38 | throw $parserErrorException; 39 | } 40 | } 41 | 42 | private function convertException(NestedValidationException|ValidationException $validationException): ParserErrorException 43 | { 44 | if ($validationException instanceof NestedValidationException) { 45 | $parserErrorException = new ParserErrorException(); 46 | foreach ($validationException->getChildren() as $childValidationException) { 47 | $parserErrorException->addParserErrorException($this->convertException($childValidationException)); 48 | } 49 | 50 | return $parserErrorException; 51 | } 52 | 53 | return new ParserErrorException( 54 | new Error( 55 | $validationException->getId(), 56 | $validationException->getMessage(), 57 | $validationException->getParams(), 58 | ) 59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Schema/SchemaInterface.php: -------------------------------------------------------------------------------- 1 | dispatchPreParses($input); 64 | 65 | if (null === $input && $this->nullable) { 66 | return null; 67 | } 68 | 69 | if (!\is_string($input)) { 70 | throw new ParserErrorException( 71 | new Error( 72 | self::ERROR_TYPE_CODE, 73 | self::ERROR_TYPE_TEMPLATE, 74 | ['given' => $this->getDataType($input)] 75 | ) 76 | ); 77 | } 78 | 79 | return $this->dispatchPostParses($input); 80 | } catch (ParserErrorException $parserErrorException) { 81 | if ($this->catch) { 82 | return ($this->catch)($input, $parserErrorException); 83 | } 84 | 85 | throw $parserErrorException; 86 | } 87 | } 88 | 89 | public function length(int $length): static 90 | { 91 | return $this->postParse(static function (string $string) use ($length) { 92 | $stringLength = \strlen($string); 93 | 94 | if ($stringLength !== $length) { 95 | throw new ParserErrorException( 96 | new Error( 97 | self::ERROR_LENGTH_CODE, 98 | self::ERROR_LENGTH_TEMPLATE, 99 | ['length' => $length, 'given' => $stringLength] 100 | ) 101 | ); 102 | } 103 | 104 | return $string; 105 | }); 106 | } 107 | 108 | public function minLength(int $minLength): static 109 | { 110 | return $this->postParse(static function (string $string) use ($minLength) { 111 | $stringLength = \strlen($string); 112 | 113 | if ($stringLength < $minLength) { 114 | throw new ParserErrorException( 115 | new Error( 116 | self::ERROR_MIN_LENGTH_CODE, 117 | self::ERROR_MIN_LENGTH_TEMPLATE, 118 | ['minLength' => $minLength, 'given' => $stringLength] 119 | ) 120 | ); 121 | } 122 | 123 | return $string; 124 | }); 125 | } 126 | 127 | public function maxLength(int $maxLength): static 128 | { 129 | return $this->postParse(static function (string $string) use ($maxLength) { 130 | $stringLength = \strlen($string); 131 | 132 | if ($stringLength > $maxLength) { 133 | throw new ParserErrorException( 134 | new Error( 135 | self::ERROR_MAX_LENGTH_CODE, 136 | self::ERROR_MAX_LENGTH_TEMPLATE, 137 | ['maxLength' => $maxLength, 'given' => $stringLength] 138 | ) 139 | ); 140 | } 141 | 142 | return $string; 143 | }); 144 | } 145 | 146 | public function includes(string $includes): static 147 | { 148 | return $this->postParse(static function (string $string) use ($includes) { 149 | if (!str_contains($string, $includes)) { 150 | throw new ParserErrorException( 151 | new Error( 152 | self::ERROR_INCLUDES_CODE, 153 | self::ERROR_INCLUDES_TEMPLATE, 154 | ['includes' => $includes, 'given' => $string] 155 | ) 156 | ); 157 | } 158 | 159 | return $string; 160 | }); 161 | } 162 | 163 | public function startsWith(string $startsWith): static 164 | { 165 | return $this->postParse(static function (string $string) use ($startsWith) { 166 | if (!str_starts_with($string, $startsWith)) { 167 | throw new ParserErrorException( 168 | new Error( 169 | self::ERROR_STARTSWITH_CODE, 170 | self::ERROR_STARTSWITH_TEMPLATE, 171 | ['startsWith' => $startsWith, 'given' => $string] 172 | ) 173 | ); 174 | } 175 | 176 | return $string; 177 | }); 178 | } 179 | 180 | public function endsWith(string $endsWith): static 181 | { 182 | return $this->postParse(static function (string $string) use ($endsWith) { 183 | if (!str_ends_with($string, $endsWith)) { 184 | throw new ParserErrorException( 185 | new Error( 186 | self::ERROR_ENDSWITH_CODE, 187 | self::ERROR_ENDSWITH_TEMPLATE, 188 | ['endsWith' => $endsWith, 'given' => $string] 189 | ) 190 | ); 191 | } 192 | 193 | return $string; 194 | }); 195 | } 196 | 197 | public function match(string $match): static 198 | { 199 | if (false === @preg_match($match, '')) { 200 | throw new \InvalidArgumentException(\sprintf('Invalid match "%s" given', $match)); 201 | } 202 | 203 | return $this->postParse(static function (string $string) use ($match) { 204 | if (0 === preg_match($match, $string)) { 205 | throw new ParserErrorException( 206 | new Error( 207 | self::ERROR_MATCH_CODE, 208 | self::ERROR_MATCH_TEMPLATE, 209 | ['match' => $match, 'given' => $string] 210 | ) 211 | ); 212 | } 213 | 214 | return $string; 215 | }); 216 | } 217 | 218 | public function email(): static 219 | { 220 | return $this->postParse(static function (string $string) { 221 | if (!filter_var($string, FILTER_VALIDATE_EMAIL)) { 222 | throw new ParserErrorException( 223 | new Error( 224 | self::ERROR_EMAIL_CODE, 225 | self::ERROR_EMAIL_TEMPLATE, 226 | ['given' => $string] 227 | ) 228 | ); 229 | } 230 | 231 | return $string; 232 | }); 233 | } 234 | 235 | public function ipV4(): static 236 | { 237 | return $this->postParse(static function (string $string) { 238 | if (!filter_var($string, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { 239 | throw new ParserErrorException( 240 | new Error( 241 | self::ERROR_IP_CODE, 242 | self::ERROR_IP_TEMPLATE, 243 | ['version' => 'v4', 'given' => $string] 244 | ) 245 | ); 246 | } 247 | 248 | return $string; 249 | }); 250 | } 251 | 252 | public function ipV6(): static 253 | { 254 | return $this->postParse(static function (string $string) { 255 | if (!filter_var($string, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { 256 | throw new ParserErrorException( 257 | new Error( 258 | self::ERROR_IP_CODE, 259 | self::ERROR_IP_TEMPLATE, 260 | ['version' => 'v6', 'given' => $string] 261 | ) 262 | ); 263 | } 264 | 265 | return $string; 266 | }); 267 | } 268 | 269 | public function url(): static 270 | { 271 | return $this->postParse(static function (string $string) { 272 | if (!filter_var($string, FILTER_VALIDATE_URL)) { 273 | throw new ParserErrorException( 274 | new Error( 275 | self::ERROR_URL_CODE, 276 | self::ERROR_URL_TEMPLATE, 277 | ['given' => $string] 278 | ) 279 | ); 280 | } 281 | 282 | return $string; 283 | }); 284 | } 285 | 286 | public function uuidV4(): static 287 | { 288 | return $this->postParse(static function (string $string) { 289 | if (0 === preg_match(self::UUID_V4_PATTERN, $string)) { 290 | throw new ParserErrorException( 291 | new Error( 292 | self::ERROR_UUID_CODE, 293 | self::ERROR_UUID_TEMPLATE, 294 | ['version' => 'v4', 'given' => $string] 295 | ) 296 | ); 297 | } 298 | 299 | return $string; 300 | }); 301 | } 302 | 303 | public function uuidV5(): static 304 | { 305 | return $this->postParse(static function (string $string) { 306 | if (0 === preg_match(self::UUID_V5_PATTERN, $string)) { 307 | throw new ParserErrorException( 308 | new Error( 309 | self::ERROR_UUID_CODE, 310 | self::ERROR_UUID_TEMPLATE, 311 | ['version' => 'v5', 'given' => $string] 312 | ) 313 | ); 314 | } 315 | 316 | return $string; 317 | }); 318 | } 319 | 320 | public function trim(): static 321 | { 322 | return $this->postParse(static fn (string $string) => trim($string)); 323 | } 324 | 325 | public function trimStart(): static 326 | { 327 | return $this->postParse(static fn (string $string) => ltrim($string)); 328 | } 329 | 330 | public function trimEnd(): static 331 | { 332 | return $this->postParse(static fn (string $string) => rtrim($string)); 333 | } 334 | 335 | public function toLowerCase(): static 336 | { 337 | return $this->postParse(static fn (string $string) => strtolower($string)); 338 | } 339 | 340 | public function toUpperCase(): static 341 | { 342 | return $this->postParse(static fn (string $string) => strtoupper($string)); 343 | } 344 | 345 | public function toDateTime(): DateTimeSchema 346 | { 347 | return (new DateTimeSchema())->preParse(function ($input) { 348 | /** @var null|string $input */ 349 | $input = $this->parse($input); 350 | 351 | if (null === $input) { 352 | return null; 353 | } 354 | 355 | try { 356 | $dateTime = new \DateTimeImmutable($input); 357 | 358 | $errors = \DateTimeImmutable::getLastErrors(); 359 | 360 | // @infection-ignore-all: php < 8.2 returned an array even if there are no errors 361 | if (false === $errors || 0 === $errors['warning_count'] && 0 === $errors['error_count']) { 362 | return $dateTime; 363 | } 364 | } catch (\Exception) { // NOSONAR: supress the exception to throw a more specific one 365 | } 366 | 367 | throw new ParserErrorException( 368 | new Error( 369 | self::ERROR_DATETIME_CODE, 370 | self::ERROR_DATETIME_TEMPLATE, 371 | ['given' => $input] 372 | ) 373 | ); 374 | })->nullable($this->nullable); 375 | } 376 | 377 | public function toFloat(): FloatSchema 378 | { 379 | return (new FloatSchema())->preParse(function ($input) { 380 | /** @var null|string $input */ 381 | $input = $this->parse($input); 382 | 383 | if (null === $input) { 384 | return null; 385 | } 386 | 387 | $floatInput = (float) $input; 388 | 389 | if ((string) $floatInput !== $input) { 390 | throw new ParserErrorException( 391 | new Error( 392 | self::ERROR_FLOAT_CODE, 393 | self::ERROR_FLOAT_TEMPLATE, 394 | ['given' => $input] 395 | ) 396 | ); 397 | } 398 | 399 | return $floatInput; 400 | })->nullable($this->nullable); 401 | } 402 | 403 | public function toInt(): IntSchema 404 | { 405 | return (new IntSchema())->preParse(function ($input) { 406 | /** @var null|string $input */ 407 | $input = $this->parse($input); 408 | 409 | if (null === $input) { 410 | return null; 411 | } 412 | 413 | $intInput = (int) $input; 414 | 415 | if ((string) $intInput !== $input) { 416 | throw new ParserErrorException( 417 | new Error( 418 | self::ERROR_INT_CODE, 419 | self::ERROR_INT_TEMPLATE, 420 | ['given' => $input] 421 | ) 422 | ); 423 | } 424 | 425 | return $intInput; 426 | })->nullable($this->nullable); 427 | } 428 | } 429 | -------------------------------------------------------------------------------- /src/Schema/TupleSchema.php: -------------------------------------------------------------------------------- 1 | 26 | */ 27 | private array $schemas; 28 | 29 | /** 30 | * @param array $schemas 31 | */ 32 | public function __construct(array $schemas) 33 | { 34 | foreach ($schemas as $i => $schema) { 35 | if (!$schema instanceof SchemaInterface) { 36 | throw new \InvalidArgumentException( 37 | \sprintf( 38 | 'Argument #1 value of #%s ($schemas) must be of type %s, %s given', 39 | $i, 40 | SchemaInterface::class, 41 | $this->getDataType($schema) 42 | ) 43 | ); 44 | } 45 | } 46 | 47 | $this->schemas = $schemas; 48 | } 49 | 50 | public function parse(mixed $input): mixed 51 | { 52 | try { 53 | $input = $this->dispatchPreParses($input); 54 | 55 | if (null === $input && $this->nullable) { 56 | return null; 57 | } 58 | 59 | if (!\is_array($input)) { 60 | throw new ParserErrorException( 61 | new Error( 62 | self::ERROR_TYPE_CODE, 63 | self::ERROR_TYPE_TEMPLATE, 64 | ['given' => $this->getDataType($input)] 65 | ) 66 | ); 67 | } 68 | 69 | $childrenParserErrorException = new ParserErrorException(); 70 | 71 | $output = []; 72 | 73 | foreach ($this->schemas as $i => $schema) { 74 | if (!isset($input[$i])) { 75 | $childrenParserErrorException->addError(new Error( 76 | self::ERROR_MISSING_INDEX_CODE, 77 | self::ERROR_MISSING_INDEX_TEMPLATE, 78 | ['index' => $i] 79 | ), $i); 80 | 81 | continue; 82 | } 83 | 84 | try { 85 | $output[$i] = $schema->parse($input[$i]); 86 | } catch (ParserErrorException $childParserErrorException) { 87 | $childrenParserErrorException->addParserErrorException($childParserErrorException, $i); 88 | } 89 | } 90 | 91 | $inputCount = \count($input); 92 | $schemaCount = \count($this->schemas); 93 | 94 | for ($i = $schemaCount; $i < $inputCount; ++$i) { 95 | $childrenParserErrorException->addError(new Error( 96 | self::ERROR_ADDITIONAL_INDEX_CODE, 97 | self::ERROR_ADDITIONAL_INDEX_TEMPLATE, 98 | ['index' => $i] 99 | ), $i); 100 | } 101 | 102 | if ($childrenParserErrorException->hasError()) { 103 | throw $childrenParserErrorException; 104 | } 105 | 106 | return $this->dispatchPostParses($output); 107 | } catch (ParserErrorException $parserErrorException) { 108 | if ($this->catch) { 109 | return ($this->catch)($input, $parserErrorException); 110 | } 111 | 112 | throw $parserErrorException; 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/Schema/UnionSchema.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | private array $schemas; 15 | 16 | /** 17 | * @param array $schemas 18 | */ 19 | public function __construct(array $schemas) 20 | { 21 | foreach ($schemas as $i => $schema) { 22 | if (!$schema instanceof SchemaInterface) { 23 | throw new \InvalidArgumentException( 24 | \sprintf( 25 | 'Argument #1 value of #%s ($schemas) must be of type %s, %s given', 26 | $i, 27 | SchemaInterface::class, 28 | $this->getDataType($schema) 29 | ) 30 | ); 31 | } 32 | } 33 | 34 | $this->schemas = $schemas; 35 | } 36 | 37 | public function parse(mixed $input): mixed 38 | { 39 | try { 40 | $input = $this->dispatchPreParses($input); 41 | 42 | if (null === $input && $this->nullable) { 43 | return null; 44 | } 45 | 46 | $output = $this->parseSchemas($input); 47 | 48 | return $this->dispatchPostParses($output); 49 | } catch (ParserErrorException $parserErrorException) { 50 | if ($this->catch) { 51 | return ($this->catch)($input, $parserErrorException); 52 | } 53 | 54 | throw $parserErrorException; 55 | } 56 | } 57 | 58 | private function parseSchemas(mixed $input): mixed 59 | { 60 | $parserErrorException = new ParserErrorException(); 61 | 62 | foreach ($this->schemas as $schema) { 63 | try { 64 | return $schema->parse($input); 65 | } catch (ParserErrorException $childParserErrorException) { 66 | $parserErrorException->addParserErrorException($childParserErrorException); 67 | } 68 | } 69 | 70 | throw $parserErrorException; 71 | } 72 | } 73 | --------------------------------------------------------------------------------