diff --git a/docs/pages/docs/features/validation/index.md b/docs/pages/docs/features/validation/index.md index c4c7a18dfc84f611c4f461bbb8d264a8bbdd0398..5f8df33b6a18f51a64f38aa8b70d5c76091a1e0d 100644 --- a/docs/pages/docs/features/validation/index.md +++ b/docs/pages/docs/features/validation/index.md @@ -200,6 +200,7 @@ use Distantmagic\Resonance\HttpResponderInterface; use Distantmagic\Resonance\RequestMethod; use Distantmagic\Resonance\SingletonCollection; use Ds\Map; +use Ds\Set; use Swoole\Http\Request; use Swoole\Http\Response; @@ -220,7 +221,7 @@ final readonly class BlogPostStore extends HttpController } /** - * @param Map<string,string> $errors + * @param Map<string,Set<string>> $errors */ #[ValidationErrorsHandler] public function handleValidationErrors( diff --git a/src/Constraint.php b/src/Constraint.php new file mode 100644 index 0000000000000000000000000000000000000000..75be005acf0141bd93e37f18c3d4d864b2d8278c --- /dev/null +++ b/src/Constraint.php @@ -0,0 +1,90 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance; + +use LogicException; + +/** + * @psalm-import-type PJsonSchema from JsonSchemableInterface + */ +abstract readonly class Constraint implements JsonSchemableInterface +{ + abstract public function default(mixed $defaultValue): self; + + abstract public function nullable(): self; + + abstract public function optional(): self; + + /** + * @return PJsonSchema + */ + abstract protected function doConvertToJsonSchema(): array|object; + + /** + * @param mixed $notValidatedData explicitly mixed for typechecks + */ + abstract protected function doValidate(mixed $notValidatedData, ConstraintPath $path): ConstraintResult; + + public function __construct( + public ?ConstraintDefaultValue $defaultValue = null, + public bool $isNullable = false, + public bool $isRequired = true, + ) {} + + /** + * @return PJsonSchema + */ + public function toJsonSchema(): array|object + { + $jsonSchema = $this->doConvertToJsonSchema(); + + if (is_object($jsonSchema)) { + return $jsonSchema; + } + + if ($this->defaultValue) { + /** + * @var mixed explicitly mixed for typechecks + */ + $jsonSchema['default'] = $this->defaultValue->defaultValue; + } + + if ($this->isNullable) { + if (!isset($jsonSchema['type'])) { + throw new LogicException('Schema cannot be nullable without a type'); + } + + if (is_array($jsonSchema['type'])) { + $jsonSchema['type'] = ['null', ...$jsonSchema['type']]; + } else { + $jsonSchema['type'] = ['null', $jsonSchema['type']]; + } + } + + /** + * @var PJsonSchema + */ + return $jsonSchema; + } + + /** + * @param mixed $notValidatedData explicitly mixed for typechecks + */ + public function validate( + mixed $notValidatedData, + ConstraintPath $path = new ConstraintPath(), + ): ConstraintResult { + if ($this->isNullable && is_null($notValidatedData)) { + return new ConstraintResult( + castedData: null, + path: $path, + reason: ConstraintReason::Ok, + status: ConstraintResultStatus::Valid, + ); + } + + return $this->doValidate($notValidatedData, $path); + } +} diff --git a/src/Constraint/AnyConstraint.php b/src/Constraint/AnyConstraint.php new file mode 100644 index 0000000000000000000000000000000000000000..0baab197962c60fb89649ac9aca82c17a9f8e332 --- /dev/null +++ b/src/Constraint/AnyConstraint.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance\Constraint; + +use Distantmagic\Resonance\Constraint; +use Distantmagic\Resonance\ConstraintDefaultValue; +use Distantmagic\Resonance\ConstraintPath; +use Distantmagic\Resonance\ConstraintReason; +use Distantmagic\Resonance\ConstraintResult; +use Distantmagic\Resonance\ConstraintResultStatus; +use stdClass; + +readonly class AnyConstraint extends Constraint +{ + public function default(mixed $defaultValue): self + { + return new self( + defaultValue: new ConstraintDefaultValue($defaultValue), + isNullable: $this->isNullable, + isRequired: $this->isRequired, + ); + } + + public function nullable(): self + { + return new self( + defaultValue: $this->defaultValue ?? new ConstraintDefaultValue(null), + isNullable: true, + isRequired: $this->isRequired, + ); + } + + public function optional(): self + { + return new self( + defaultValue: $this->defaultValue, + isNullable: $this->isNullable, + isRequired: false, + ); + } + + protected function doConvertToJsonSchema(): object + { + return new stdClass(); + } + + protected function doValidate(mixed $notValidatedData, ConstraintPath $path): ConstraintResult + { + return new ConstraintResult( + castedData: $notValidatedData, + path: $path, + reason: ConstraintReason::InvalidEnumValue, + status: ConstraintResultStatus::Valid, + ); + } +} diff --git a/src/Constraint/AnyConstraintTest.php b/src/Constraint/AnyConstraintTest.php new file mode 100644 index 0000000000000000000000000000000000000000..aad39b4e1beb536475aaf60f68f2e06a7de8fc6c --- /dev/null +++ b/src/Constraint/AnyConstraintTest.php @@ -0,0 +1,44 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance\Constraint; + +use PHPUnit\Framework\TestCase; +use stdClass; + +/** + * @coversNothing + * + * @internal + */ +final class AnyConstraintTest extends TestCase +{ + public function test_is_converted_optionally_to_json_schema(): void + { + $constraint = new AnyConstraint(); + + self::assertEquals(new stdClass(), $constraint->optional()->toJsonSchema()); + } + + public function test_is_converted_to_json_schema(): void + { + $constraint = new AnyConstraint(); + + self::assertEquals(new stdClass(), $constraint->toJsonSchema()); + } + + public function test_nullable_is_converted_to_json_schema(): void + { + $constraint = new AnyConstraint(); + + self::assertEquals(new stdClass(), $constraint->nullable()->toJsonSchema()); + } + + public function test_validates(): void + { + $constraint = new AnyConstraint(); + + self::assertTrue($constraint->validate('foo')->status->isValid()); + } +} diff --git a/src/Constraint/ConstConstraint.php b/src/Constraint/ConstConstraint.php new file mode 100644 index 0000000000000000000000000000000000000000..2bfc053ba4b55a1250ff4efc7d4e866e3ca894ce --- /dev/null +++ b/src/Constraint/ConstConstraint.php @@ -0,0 +1,64 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance\Constraint; + +use Distantmagic\Resonance\Constraint; +use Distantmagic\Resonance\ConstraintPath; +use Distantmagic\Resonance\ConstraintReason; +use Distantmagic\Resonance\ConstraintResult; +use Distantmagic\Resonance\ConstraintResultStatus; +use LogicException; + +readonly class ConstConstraint extends Constraint +{ + /** + * @param float|int|non-empty-string $constValue + */ + public function __construct(public float|int|string $constValue) + { + parent::__construct(); + } + + public function default(mixed $defaultValue): never + { + throw new LogicException('Const constraint cannot have a default value'); + } + + public function nullable(): never + { + throw new LogicException('Const constraint cannot be nullable'); + } + + public function optional(): never + { + throw new LogicException('Const constraint cannot be optional'); + } + + protected function doConvertToJsonSchema(): array + { + return [ + 'const' => $this->constValue, + ]; + } + + protected function doValidate(mixed $notValidatedData, ConstraintPath $path): ConstraintResult + { + if ($notValidatedData !== $this->constValue) { + return new ConstraintResult( + castedData: $notValidatedData, + path: $path, + reason: ConstraintReason::InvalidEnumValue, + status: ConstraintResultStatus::Invalid, + ); + } + + return new ConstraintResult( + castedData: $notValidatedData, + path: $path, + reason: ConstraintReason::Ok, + status: ConstraintResultStatus::Valid, + ); + } +} diff --git a/src/Constraint/ConstConstraintTest.php b/src/Constraint/ConstConstraintTest.php new file mode 100644 index 0000000000000000000000000000000000000000..658bcb8ceae2469d78a42f4ba02eae38a2dc8e5d --- /dev/null +++ b/src/Constraint/ConstConstraintTest.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance\Constraint; + +use PHPUnit\Framework\TestCase; + +/** + * @coversNothing + * + * @internal + */ +final class ConstConstraintTest extends TestCase +{ + public function test_is_converted_to_json_schema(): void + { + $constraint = new ConstConstraint(2); + + self::assertEquals([ + 'const' => 2, + ], $constraint->toJsonSchema()); + } + + public function test_validates(): void + { + $constraint = new ConstConstraint(4); + + self::assertTrue($constraint->validate(4)->status->isValid()); + self::assertFalse($constraint->validate(4.5)->status->isValid()); + } +} diff --git a/src/Constraint/EnumConstraint.php b/src/Constraint/EnumConstraint.php new file mode 100644 index 0000000000000000000000000000000000000000..bb14cc29abfcc40b8008db08692b52db9ab60bd0 --- /dev/null +++ b/src/Constraint/EnumConstraint.php @@ -0,0 +1,97 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance\Constraint; + +use Distantmagic\Resonance\Constraint; +use Distantmagic\Resonance\ConstraintDefaultValue; +use Distantmagic\Resonance\ConstraintPath; +use Distantmagic\Resonance\ConstraintReason; +use Distantmagic\Resonance\ConstraintResult; +use Distantmagic\Resonance\ConstraintResultStatus; + +readonly class EnumConstraint extends Constraint +{ + /** + * @param array<non-empty-string> $values + */ + public function __construct( + public array $values, + ?ConstraintDefaultValue $defaultValue = null, + bool $isNullable = false, + bool $isRequired = true, + ) { + parent::__construct( + defaultValue: $defaultValue, + isNullable: $isNullable, + isRequired: $isRequired, + ); + } + + public function default(mixed $defaultValue): self + { + return new self( + defaultValue: new ConstraintDefaultValue($defaultValue), + values: $this->values, + isNullable: $this->isNullable, + isRequired: $this->isRequired, + ); + } + + public function nullable(): self + { + return new self( + defaultValue: $this->defaultValue ?? new ConstraintDefaultValue(null), + values: $this->values, + isNullable: true, + isRequired: $this->isRequired, + ); + } + + public function optional(): self + { + return new self( + defaultValue: $this->defaultValue, + values: $this->values, + isNullable: $this->isNullable, + isRequired: false, + ); + } + + protected function doConvertToJsonSchema(): array + { + return [ + 'type' => 'string', + 'enum' => $this->values, + ]; + } + + protected function doValidate(mixed $notValidatedData, ConstraintPath $path): ConstraintResult + { + if (!is_string($notValidatedData)) { + return new ConstraintResult( + castedData: $notValidatedData, + path: $path, + reason: ConstraintReason::InvalidDataType, + status: ConstraintResultStatus::Invalid, + ); + } + + if (!in_array($notValidatedData, $this->values, true)) { + return new ConstraintResult( + castedData: $notValidatedData, + path: $path, + reason: ConstraintReason::InvalidEnumValue, + status: ConstraintResultStatus::Invalid, + ); + } + + return new ConstraintResult( + castedData: $notValidatedData, + path: $path, + reason: ConstraintReason::Ok, + status: ConstraintResultStatus::Valid, + ); + } +} diff --git a/src/Constraint/EnumConstraintTest.php b/src/Constraint/EnumConstraintTest.php new file mode 100644 index 0000000000000000000000000000000000000000..7d6a20c1b23e5d61bbc25c6bed97230d07e5ad3a --- /dev/null +++ b/src/Constraint/EnumConstraintTest.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance\Constraint; + +use PHPUnit\Framework\TestCase; + +/** + * @coversNothing + * + * @internal + */ +final class EnumConstraintTest extends TestCase +{ + public function test_is_converted_optionally_to_json_schema(): void + { + $constraint = new EnumConstraint(['foo', 'bar']); + + self::assertEquals([ + 'type' => 'string', + 'enum' => ['foo', 'bar'], + ], $constraint->optional()->toJsonSchema()); + } + + public function test_is_converted_to_json_schema(): void + { + $constraint = new EnumConstraint(['foo', 'bar']); + + self::assertEquals([ + 'type' => 'string', + 'enum' => ['foo', 'bar'], + ], $constraint->toJsonSchema()); + } + + public function test_nullable_is_converted_to_json_schema(): void + { + $constraint = new EnumConstraint(['foo', 'bar']); + + self::assertEquals([ + 'type' => ['null', 'string'], + 'enum' => ['foo', 'bar'], + 'default' => null, + ], $constraint->nullable()->toJsonSchema()); + } + + public function test_validates(): void + { + $constraint = new EnumConstraint(['foo', 'bar']); + + self::assertTrue($constraint->validate('foo')->status->isValid()); + self::assertFalse($constraint->validate('booz')->status->isValid()); + } +} diff --git a/src/Constraint/IntegerConstraint.php b/src/Constraint/IntegerConstraint.php new file mode 100644 index 0000000000000000000000000000000000000000..764f0b45a3875a097eb481ea38065e4fe9cfaae2 --- /dev/null +++ b/src/Constraint/IntegerConstraint.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance\Constraint; + +use Distantmagic\Resonance\Constraint; +use Distantmagic\Resonance\ConstraintDefaultValue; +use Distantmagic\Resonance\ConstraintPath; +use Distantmagic\Resonance\ConstraintReason; +use Distantmagic\Resonance\ConstraintResult; +use Distantmagic\Resonance\ConstraintResultStatus; + +readonly class IntegerConstraint extends Constraint +{ + public function default(mixed $defaultValue): self + { + return new self( + defaultValue: new ConstraintDefaultValue($defaultValue), + isNullable: $this->isNullable, + isRequired: $this->isRequired, + ); + } + + public function nullable(): self + { + return new self( + defaultValue: $this->defaultValue ?? new ConstraintDefaultValue(null), + isNullable: true, + isRequired: $this->isRequired, + ); + } + + public function optional(): self + { + return new self( + defaultValue: $this->defaultValue, + isNullable: $this->isNullable, + isRequired: false, + ); + } + + protected function doConvertToJsonSchema(): array + { + return [ + 'type' => 'integer', + ]; + } + + protected function doValidate(mixed $notValidatedData, ConstraintPath $path): ConstraintResult + { + if (!is_int($notValidatedData)) { + return new ConstraintResult( + castedData: $notValidatedData, + path: $path, + reason: ConstraintReason::InvalidDataType, + status: ConstraintResultStatus::Invalid, + ); + } + + return new ConstraintResult( + castedData: $notValidatedData, + path: $path, + reason: ConstraintReason::Ok, + status: ConstraintResultStatus::Valid, + ); + } +} diff --git a/src/Constraint/IntegerConstraintTest.php b/src/Constraint/IntegerConstraintTest.php new file mode 100644 index 0000000000000000000000000000000000000000..178148c45aeccd0ec3a89640230e5431fbf1229a --- /dev/null +++ b/src/Constraint/IntegerConstraintTest.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance\Constraint; + +use PHPUnit\Framework\TestCase; + +/** + * @coversNothing + * + * @internal + */ +final class IntegerConstraintTest extends TestCase +{ + public function test_is_converted_optionally_to_json_schema(): void + { + $constraint = new IntegerConstraint(); + + self::assertEquals([ + 'type' => 'integer', + ], $constraint->optional()->toJsonSchema()); + } + + public function test_is_converted_to_json_schema(): void + { + $constraint = new IntegerConstraint(); + + self::assertEquals([ + 'type' => 'integer', + ], $constraint->toJsonSchema()); + } + + public function test_nullable_is_converted_to_json_schema(): void + { + $constraint = new IntegerConstraint(); + + self::assertEquals([ + 'type' => ['null', 'integer'], + 'default' => null, + ], $constraint->nullable()->toJsonSchema()); + } + + public function test_validates(): void + { + $constraint = new IntegerConstraint(); + + self::assertTrue($constraint->validate(5)->status->isValid()); + self::assertFalse($constraint->validate(5.5)->status->isValid()); + } +} diff --git a/src/Constraint/NumberConstraint.php b/src/Constraint/NumberConstraint.php new file mode 100644 index 0000000000000000000000000000000000000000..fc39114dffab53b9517fe781431b13cf95931ab4 --- /dev/null +++ b/src/Constraint/NumberConstraint.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance\Constraint; + +use Distantmagic\Resonance\Constraint; +use Distantmagic\Resonance\ConstraintDefaultValue; +use Distantmagic\Resonance\ConstraintPath; +use Distantmagic\Resonance\ConstraintReason; +use Distantmagic\Resonance\ConstraintResult; +use Distantmagic\Resonance\ConstraintResultStatus; + +readonly class NumberConstraint extends Constraint +{ + public function default(mixed $defaultValue): self + { + return new self( + defaultValue: new ConstraintDefaultValue($defaultValue), + isNullable: $this->isNullable, + isRequired: $this->isRequired, + ); + } + + public function nullable(): self + { + return new self( + defaultValue: $this->defaultValue ?? new ConstraintDefaultValue(null), + isNullable: true, + isRequired: $this->isRequired, + ); + } + + public function optional(): self + { + return new self( + defaultValue: $this->defaultValue, + isNullable: $this->isNullable, + isRequired: false, + ); + } + + protected function doConvertToJsonSchema(): array + { + return [ + 'type' => 'number', + ]; + } + + protected function doValidate(mixed $notValidatedData, ConstraintPath $path): ConstraintResult + { + if (!is_numeric($notValidatedData) || is_string($notValidatedData)) { + return new ConstraintResult( + castedData: $notValidatedData, + path: $path, + reason: ConstraintReason::InvalidDataType, + status: ConstraintResultStatus::Invalid, + ); + } + + return new ConstraintResult( + castedData: $notValidatedData, + path: $path, + reason: ConstraintReason::Ok, + status: ConstraintResultStatus::Valid, + ); + } +} diff --git a/src/Constraint/NumberConstraintTest.php b/src/Constraint/NumberConstraintTest.php new file mode 100644 index 0000000000000000000000000000000000000000..981bd62be8d3f41df3c4ad559db4b7eb78f249ae --- /dev/null +++ b/src/Constraint/NumberConstraintTest.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance\Constraint; + +use PHPUnit\Framework\TestCase; + +/** + * @coversNothing + * + * @internal + */ +final class NumberConstraintTest extends TestCase +{ + public function test_is_converted_optionally_to_json_schema(): void + { + $constraint = new NumberConstraint(); + + self::assertEquals([ + 'type' => 'number', + ], $constraint->optional()->toJsonSchema()); + } + + public function test_is_converted_to_json_schema(): void + { + $constraint = new NumberConstraint(); + + self::assertEquals([ + 'type' => 'number', + ], $constraint->toJsonSchema()); + } + + public function test_nullable_is_converted_to_json_schema(): void + { + $constraint = new NumberConstraint(); + + self::assertEquals([ + 'type' => ['null', 'number'], + 'default' => null, + ], $constraint->nullable()->toJsonSchema()); + } + + public function test_validates(): void + { + $constraint = new NumberConstraint(); + + self::assertTrue($constraint->validate(5)->status->isValid()); + self::assertTrue($constraint->validate(5.5)->status->isValid()); + } +} diff --git a/src/Constraint/ObjectConstraint.php b/src/Constraint/ObjectConstraint.php new file mode 100644 index 0000000000000000000000000000000000000000..3a24b775b5737f6815346f459bce7ab03ea9bf5b --- /dev/null +++ b/src/Constraint/ObjectConstraint.php @@ -0,0 +1,150 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance\Constraint; + +use Distantmagic\Resonance\Constraint; +use Distantmagic\Resonance\ConstraintDefaultValue; +use Distantmagic\Resonance\ConstraintPath; +use Distantmagic\Resonance\ConstraintReason; +use Distantmagic\Resonance\ConstraintResult; +use Distantmagic\Resonance\ConstraintResultStatus; + +final readonly class ObjectConstraint extends Constraint +{ + /** + * @param array<non-empty-string,Constraint> $properties + */ + public function __construct( + public array $properties, + ?ConstraintDefaultValue $defaultValue = null, + bool $isNullable = false, + bool $isRequired = true, + ) { + parent::__construct( + defaultValue: $defaultValue, + isNullable: $isNullable, + isRequired: $isRequired, + ); + } + + public function default(mixed $defaultValue): self + { + return new self( + properties: $this->properties, + defaultValue: new ConstraintDefaultValue($defaultValue), + isNullable: $this->isNullable, + isRequired: $this->isRequired, + ); + } + + public function nullable(): self + { + return new self( + properties: $this->properties, + defaultValue: $this->defaultValue ?? new ConstraintDefaultValue(null), + isNullable: true, + isRequired: $this->isRequired, + ); + } + + public function optional(): self + { + return new self( + properties: $this->properties, + defaultValue: $this->defaultValue, + isNullable: $this->isNullable, + isRequired: false, + ); + } + + protected function doConvertToJsonSchema(): array + { + $convertedProperties = []; + $requiredProperties = []; + + foreach ($this->properties as $name => $property) { + $convertedProperties[$name] = $property->toJsonSchema(); + + if ($property->isRequired) { + $requiredProperties[] = $name; + } + } + + return [ + 'type' => 'object', + 'properties' => $convertedProperties, + 'required' => $requiredProperties, + 'additionalProperties' => false, + ]; + } + + protected function doValidate(mixed $notValidatedData, ConstraintPath $path): ConstraintResult + { + if (!is_array($notValidatedData)) { + return new ConstraintResult( + castedData: $notValidatedData, + path: $path, + reason: ConstraintReason::InvalidDataType, + status: ConstraintResultStatus::Invalid, + ); + } + + $ret = []; + + /** + * @var list<ConstraintResult> + */ + $invalidChildStatuses = []; + + foreach ($this->properties as $name => $validator) { + if (!array_key_exists($name, $notValidatedData)) { + if ($validator->defaultValue) { + /** + * @var mixed explicitly mixed for typechecks + */ + $ret[$name] = $validator->defaultValue->defaultValue; + } elseif ($validator->isRequired) { + $invalidChildStatuses[] = new ConstraintResult( + castedData: null, + path: $path->fork($name), + reason: ConstraintReason::MissingProperty, + status: ConstraintResultStatus::Invalid, + ); + } + } else { + $childResult = $validator->validate( + notValidatedData: $notValidatedData[$name], + path: $path->fork($name) + ); + + if ($childResult->status->isValid()) { + /** + * @var mixed explicitly mixed for typechecks + */ + $ret[$name] = $childResult->castedData; + } else { + $invalidChildStatuses[] = $childResult; + } + } + } + + if (!empty($invalidChildStatuses)) { + return new ConstraintResult( + castedData: $notValidatedData, + nested: $invalidChildStatuses, + path: $path, + reason: ConstraintReason::InvalidNestedConstraint, + status: ConstraintResultStatus::Invalid, + ); + } + + return new ConstraintResult( + castedData: $ret, + path: $path, + reason: ConstraintReason::Ok, + status: ConstraintResultStatus::Valid, + ); + } +} diff --git a/src/Constraint/ObjectConstraintTest.php b/src/Constraint/ObjectConstraintTest.php new file mode 100644 index 0000000000000000000000000000000000000000..a28960ab07da4a653cde4c6d234f830fe621c84d --- /dev/null +++ b/src/Constraint/ObjectConstraintTest.php @@ -0,0 +1,234 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance\Constraint; + +use PHPUnit\Framework\TestCase; + +/** + * @coversNothing + * + * @internal + */ +final class ObjectConstraintTest extends TestCase +{ + public function test_is_converted_optionally_to_json_schema(): void + { + $constraint = new ObjectConstraint( + properties: [ + 'aaa' => new StringConstraint(), + 'bbb' => new EnumConstraint(['foo']), + ] + ); + self::assertEquals([ + 'type' => 'object', + 'properties' => [ + 'aaa' => [ + 'type' => 'string', + 'minLength' => 1, + ], + 'bbb' => [ + 'type' => 'string', + 'enum' => ['foo'], + ], + ], + 'required' => ['aaa', 'bbb'], + 'additionalProperties' => false, + ], $constraint->optional()->toJsonSchema()); + } + + public function test_is_converted_to_json_schema(): void + { + $constraint = new ObjectConstraint( + properties: [ + 'aaa' => new StringConstraint(), + 'bbb' => new EnumConstraint(['foo']), + ] + ); + self::assertEquals([ + 'type' => 'object', + 'properties' => [ + 'aaa' => [ + 'type' => 'string', + 'minLength' => 1, + ], + 'bbb' => [ + 'type' => 'string', + 'enum' => ['foo'], + ], + ], + 'required' => ['aaa', 'bbb'], + 'additionalProperties' => false, + ], $constraint->toJsonSchema()); + } + + public function test_is_converted_to_json_schema_with_optionals(): void + { + $constraint = new ObjectConstraint( + properties: [ + 'aaa' => (new StringConstraint())->optional(), + 'bbb' => (new EnumConstraint(['foo']))->default(null), + ] + ); + self::assertEquals([ + 'type' => 'object', + 'properties' => [ + 'aaa' => [ + 'type' => 'string', + 'minLength' => 1, + ], + 'bbb' => [ + 'type' => 'string', + 'enum' => ['foo'], + 'default' => null, + ], + ], + 'required' => ['bbb'], + 'additionalProperties' => false, + ], $constraint->toJsonSchema()); + } + + public function test_nullable_is_converted_to_json_schema(): void + { + $constraint = new ObjectConstraint( + properties: [ + 'aaa' => new StringConstraint(), + 'bbb' => new EnumConstraint(['foo']), + ] + ); + self::assertEquals([ + 'type' => ['null', 'object'], + 'properties' => [ + 'aaa' => [ + 'type' => 'string', + 'minLength' => 1, + ], + 'bbb' => [ + 'type' => 'string', + 'enum' => ['foo'], + ], + ], + 'required' => ['aaa', 'bbb'], + 'additionalProperties' => false, + 'default' => null, + ], $constraint->nullable()->toJsonSchema()); + } + + public function test_validates_defaults(): void + { + $constraint = new ObjectConstraint( + properties: [ + 'aaa' => (new StringConstraint())->default('hi'), + 'bbb' => new EnumConstraint(['foo']), + ] + ); + + $validatedResult = $constraint->validate([ + 'bbb' => 'foo', + ]); + + self::assertTrue($validatedResult->status->isValid()); + self::assertEquals([ + 'aaa' => 'hi', + 'bbb' => 'foo', + ], $validatedResult->castedData); + } + + public function test_validates_fail(): void + { + $constraint = new ObjectConstraint( + properties: [ + 'aaa' => new StringConstraint(), + 'bbb' => new EnumConstraint(['foo']), + ] + ); + + $validatedResult = $constraint->validate([ + 'aaa' => 'hi', + 'bbb' => 'woot', + ]); + self::assertFalse($validatedResult->status->isValid()); + self::assertEquals([ + '' => 'invalid_nested_constraint', + 'bbb' => 'invalid_enum_value', + ], $validatedResult->getErrors()->toArray()); + } + + public function test_validates_nullable(): void + { + $constraint = new ObjectConstraint( + properties: [ + 'aaa' => (new StringConstraint())->nullable(), + 'bbb' => new EnumConstraint(['foo']), + ] + ); + + $validatedResult = $constraint->validate([ + 'bbb' => 'foo', + ]); + + self::assertTrue($validatedResult->status->isValid()); + self::assertEquals([ + 'aaa' => null, + 'bbb' => 'foo', + ], $validatedResult->castedData); + } + + public function test_validates_nullable_null(): void + { + $constraint = new ObjectConstraint( + properties: [ + 'aaa' => (new StringConstraint())->nullable(), + 'bbb' => new EnumConstraint(['foo']), + ] + ); + + $validatedResult = $constraint->validate([ + 'aaa' => null, + 'bbb' => 'foo', + ]); + + self::assertTrue($validatedResult->status->isValid()); + self::assertEquals([ + 'aaa' => null, + 'bbb' => 'foo', + ], $validatedResult->castedData); + } + + public function test_validates_ok(): void + { + $constraint = new ObjectConstraint( + properties: [ + 'aaa' => new StringConstraint(), + 'bbb' => new EnumConstraint(['foo']), + ] + ); + + $validatedResult = $constraint->validate([ + 'aaa' => 'hi', + 'bbb' => 'foo', + ]); + + self::assertTrue($validatedResult->status->isValid()); + } + + public function test_validates_optional(): void + { + $constraint = new ObjectConstraint( + properties: [ + 'aaa' => (new StringConstraint())->optional(), + 'bbb' => new EnumConstraint(['foo']), + ] + ); + + $validatedResult = $constraint->validate([ + 'bbb' => 'foo', + ]); + + self::assertTrue($validatedResult->status->isValid()); + self::assertEquals([ + 'bbb' => 'foo', + ], $validatedResult->castedData); + } +} diff --git a/src/Constraint/StringConstraint.php b/src/Constraint/StringConstraint.php new file mode 100644 index 0000000000000000000000000000000000000000..47432d07399406d32bdaec2b23484a9c2d43737b --- /dev/null +++ b/src/Constraint/StringConstraint.php @@ -0,0 +1,109 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance\Constraint; + +use Distantmagic\Resonance\Constraint; +use Distantmagic\Resonance\ConstraintDefaultValue; +use Distantmagic\Resonance\ConstraintPath; +use Distantmagic\Resonance\ConstraintReason; +use Distantmagic\Resonance\ConstraintResult; +use Distantmagic\Resonance\ConstraintResultStatus; +use Distantmagic\Resonance\ConstraintStringFormat; +use RuntimeException; + +readonly class StringConstraint extends Constraint +{ + public function __construct( + public ?ConstraintStringFormat $format = null, + ?ConstraintDefaultValue $defaultValue = null, + bool $isNullable = false, + bool $isRequired = true, + ) { + parent::__construct( + defaultValue: $defaultValue, + isNullable: $isNullable, + isRequired: $isRequired, + ); + } + + public function default(mixed $defaultValue): self + { + return new self( + defaultValue: new ConstraintDefaultValue($defaultValue), + format: $this->format, + isNullable: $this->isNullable, + isRequired: $this->isRequired, + ); + } + + public function nullable(): self + { + return new self( + defaultValue: $this->defaultValue ?? new ConstraintDefaultValue(null), + format: $this->format, + isNullable: true, + isRequired: $this->isRequired, + ); + } + + public function optional(): self + { + return new self( + defaultValue: $this->defaultValue, + format: $this->format, + isNullable: $this->isNullable, + isRequired: false, + ); + } + + protected function doConvertToJsonSchema(): array + { + return [ + 'type' => 'string', + 'minLength' => 1, + ]; + } + + protected function doValidate(mixed $notValidatedData, ConstraintPath $path): ConstraintResult + { + if (!is_string($notValidatedData) || empty($notValidatedData)) { + return new ConstraintResult( + castedData: $notValidatedData, + path: $path, + reason: ConstraintReason::InvalidDataType, + status: ConstraintResultStatus::Invalid, + ); + } + + if (!$this->format) { + return new ConstraintResult( + castedData: $notValidatedData, + path: $path, + reason: ConstraintReason::Ok, + status: ConstraintResultStatus::Valid, + ); + } + + if (ConstraintStringFormat::Uuid === $this->format) { + if (uuid_is_valid($notValidatedData)) { + return new ConstraintResult( + castedData: $notValidatedData, + path: $path, + reason: ConstraintReason::Ok, + status: ConstraintResultStatus::Valid, + ); + } + + return new ConstraintResult( + castedData: $notValidatedData, + path: $path, + reason: ConstraintReason::InvalidFormat, + status: ConstraintResultStatus::Invalid, + ); + } + + throw new RuntimeException('Unknown string format'); + } +} diff --git a/src/Constraint/StringConstraintTest.php b/src/Constraint/StringConstraintTest.php new file mode 100644 index 0000000000000000000000000000000000000000..9d6908a89ce9c16100c5d8f789cb3b357422a14d --- /dev/null +++ b/src/Constraint/StringConstraintTest.php @@ -0,0 +1,62 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance\Constraint; + +use Distantmagic\Resonance\ConstraintStringFormat; +use PHPUnit\Framework\TestCase; + +/** + * @coversNothing + * + * @internal + */ +final class StringConstraintTest extends TestCase +{ + public function test_is_converted_optionally_to_json_schema(): void + { + $constraint = new StringConstraint(); + + self::assertEquals([ + 'type' => 'string', + 'minLength' => 1, + ], $constraint->optional()->toJsonSchema()); + } + + public function test_is_converted_to_json_schema(): void + { + $constraint = new StringConstraint(); + + self::assertEquals([ + 'type' => 'string', + 'minLength' => 1, + ], $constraint->toJsonSchema()); + } + + public function test_nullable_is_converted_to_json_schema(): void + { + $constraint = new StringConstraint(); + + self::assertEquals([ + 'type' => ['null', 'string'], + 'minLength' => 1, + 'default' => null, + ], $constraint->nullable()->toJsonSchema()); + } + + public function test_validates(): void + { + $constraint = new StringConstraint(); + + self::assertTrue($constraint->validate('hi')->status->isValid()); + } + + public function test_validates_uuid(): void + { + $constraint = new StringConstraint(format: ConstraintStringFormat::Uuid); + + self::assertFalse($constraint->validate('hi')->status->isValid()); + self::assertTrue($constraint->validate('ccaf9acc-123e-4ff3-85da-1117342b0e02')->status->isValid()); + } +} diff --git a/src/Constraint/TupleConstraint.php b/src/Constraint/TupleConstraint.php new file mode 100644 index 0000000000000000000000000000000000000000..67a6d96a3436563caab78ef23a99cccb79b55905 --- /dev/null +++ b/src/Constraint/TupleConstraint.php @@ -0,0 +1,156 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance\Constraint; + +use Distantmagic\Resonance\Constraint; +use Distantmagic\Resonance\ConstraintDefaultValue; +use Distantmagic\Resonance\ConstraintPath; +use Distantmagic\Resonance\ConstraintReason; +use Distantmagic\Resonance\ConstraintResult; +use Distantmagic\Resonance\ConstraintResultStatus; + +final readonly class TupleConstraint extends Constraint +{ + /** + * @param list<Constraint> $items + */ + public function __construct( + public array $items, + ?ConstraintDefaultValue $defaultValue = null, + bool $isNullable = false, + bool $isRequired = true, + ) { + parent::__construct( + defaultValue: $defaultValue, + isNullable: $isNullable, + isRequired: $isRequired, + ); + } + + public function default(mixed $defaultValue): self + { + return new self( + items: $this->items, + defaultValue: new ConstraintDefaultValue($defaultValue), + isNullable: $this->isNullable, + isRequired: $this->isRequired, + ); + } + + public function nullable(): self + { + return new self( + items: $this->items, + defaultValue: $this->defaultValue ?? new ConstraintDefaultValue(null), + isNullable: true, + isRequired: $this->isRequired, + ); + } + + public function optional(): self + { + return new self( + items: $this->items, + defaultValue: $this->defaultValue, + isNullable: $this->isNullable, + isRequired: false, + ); + } + + protected function doConvertToJsonSchema(): array + { + $prefixItems = []; + + foreach ($this->items as $item) { + $prefixItems[] = $item->toJsonSchema(); + } + + return [ + 'type' => 'array', + 'items' => false, + 'prefixItems' => $prefixItems, + ]; + } + + protected function doValidate(mixed $notValidatedData, ConstraintPath $path): ConstraintResult + { + if (!is_array($notValidatedData)) { + return new ConstraintResult( + castedData: $notValidatedData, + path: $path, + reason: ConstraintReason::InvalidDataType, + status: ConstraintResultStatus::Invalid, + ); + } + + $i = 0; + + /** + * @var list<mixed> explicitly mixed for typechecks + */ + $ret = []; + + /** + * @var list<ConstraintResult> + */ + $invalidChildStatuses = []; + + /** + * @var mixed $item explicitly mixed for typechecks + */ + foreach ($this->items as $validator) { + if (!array_key_exists($i, $notValidatedData)) { + if ($validator->defaultValue) { + /** + * @var mixed explicitly mixed for typechecks + */ + $ret[] = $validator->defaultValue->defaultValue; + } elseif ($validator->isRequired) { + $invalidChildStatuses[] = new ConstraintResult( + castedData: null, + path: $path->fork((string) $i), + reason: ConstraintReason::MissingProperty, + status: ConstraintResultStatus::Invalid, + ); + } else { + $ret[] = null; + } + } else { + $childResult = $validator->validate( + notValidatedData: $notValidatedData[$i], + path: $path->fork((string) $i) + ); + + if ($childResult->status->isValid()) { + /** + * @var mixed explicitly mixed for typechecks + */ + $ret[] = $childResult->castedData; + } else { + $invalidChildStatuses[] = $childResult; + } + } + + ++$i; + } + + if (!empty($invalidChildStatuses)) { + return new ConstraintResult( + castedData: $notValidatedData, + nested: $invalidChildStatuses, + path: $path, + reason: ConstraintReason::InvalidNestedConstraint, + status: ConstraintResultStatus::Invalid, + ); + } + + return new ConstraintResult( + castedData: $ret, + path: $path, + reason: ConstraintReason::Ok, + status: ConstraintResultStatus::Valid, + ); + } +} diff --git a/src/Constraint/TupleConstraintTest.php b/src/Constraint/TupleConstraintTest.php new file mode 100644 index 0000000000000000000000000000000000000000..9949c265e27e66dbecf616ad4a30ea7b6a0f517c --- /dev/null +++ b/src/Constraint/TupleConstraintTest.php @@ -0,0 +1,133 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance\Constraint; + +use PHPUnit\Framework\TestCase; + +/** + * @coversNothing + * + * @internal + */ +final class TupleConstraintTest extends TestCase +{ + public function test_is_converted_optionally_to_json_schema(): void + { + $constraint = new TupleConstraint( + items: [ + new StringConstraint(), + new EnumConstraint(['foo']), + ] + ); + self::assertEquals([ + 'type' => 'array', + 'items' => false, + 'prefixItems' => [ + [ + 'type' => 'string', + 'minLength' => 1, + ], + [ + 'type' => 'string', + 'enum' => ['foo'], + ], + ], + ], $constraint->optional()->toJsonSchema()); + } + + public function test_is_converted_to_json_schema(): void + { + $constraint = new TupleConstraint( + items: [ + new StringConstraint(), + new NumberConstraint(), + ] + ); + + self::assertEquals([ + 'type' => 'array', + 'items' => false, + 'prefixItems' => [ + [ + 'type' => 'string', + 'minLength' => 1, + ], + [ + 'type' => 'number', + ], + ], + ], $constraint->toJsonSchema()); + } + + public function test_nullable_is_converted_to_json_schema(): void + { + $constraint = new TupleConstraint( + items: [ + new StringConstraint(), + new NumberConstraint(), + ] + ); + + self::assertEquals([ + 'type' => ['null', 'array'], + 'items' => false, + 'prefixItems' => [ + [ + 'type' => 'string', + 'minLength' => 1, + ], + [ + 'type' => 'number', + ], + ], + 'default' => null, + ], $constraint->nullable()->toJsonSchema()); + } + + public function test_validates_fail(): void + { + $constraint = new TupleConstraint( + items: [ + new StringConstraint(), + new NumberConstraint(), + ] + ); + + $validatedResult = $constraint->validate(['hi', 'ho']); + + self::assertFalse($validatedResult->status->isValid()); + self::assertEquals([ + '' => 'invalid_nested_constraint', + '1' => 'invalid_data_type', + ], $validatedResult->getErrors()->toArray()); + } + + public function test_validates_ok(): void + { + $constraint = new TupleConstraint( + items: [ + new StringConstraint(), + new NumberConstraint(), + ] + ); + + self::assertTrue($constraint->validate(['hi', 5])->status->isValid()); + self::assertFalse($constraint->validate(['hi', 'ho'])->status->isValid()); + } + + public function test_validates_ok_default(): void + { + $constraint = new TupleConstraint( + items: [ + new EnumConstraint(['hi', 'ho']), + new AnyConstraint(), + (new StringConstraint())->nullable(), + ] + ); + + self::assertTrue($constraint->validate(['hi', 5])->status->isValid()); + self::assertTrue($constraint->validate(['hi', 5, null])->status->isValid()); + } +} diff --git a/src/ConstraintDefaultValue.php b/src/ConstraintDefaultValue.php new file mode 100644 index 0000000000000000000000000000000000000000..3718b1ebc83a0c316a88c109ee47c5888f8467ec --- /dev/null +++ b/src/ConstraintDefaultValue.php @@ -0,0 +1,10 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance; + +readonly class ConstraintDefaultValue +{ + public function __construct(public mixed $defaultValue) {} +} diff --git a/src/ConstraintPath.php b/src/ConstraintPath.php new file mode 100644 index 0000000000000000000000000000000000000000..ba705907bf79b47f0e60a9911a996e53ca60dabb --- /dev/null +++ b/src/ConstraintPath.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance; + +use Stringable; + +readonly class ConstraintPath implements Stringable +{ + /** + * @param array<string> $path + */ + public function __construct(private array $path = []) {} + + public function __toString(): string + { + return implode('.', $this->path); + } + + public function fork(string $next): self + { + return new self([...$this->path, $next]); + } +} diff --git a/src/ConstraintReason.php b/src/ConstraintReason.php new file mode 100644 index 0000000000000000000000000000000000000000..bc85854314c7b19337e5115f872272a3088b55b8 --- /dev/null +++ b/src/ConstraintReason.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance; + +enum ConstraintReason: string +{ + case InvalidDataType = 'invalid_data_type'; + case InvalidEnumValue = 'invalid_enum_value'; + case InvalidFormat = 'invalid_format'; + case InvalidNestedConstraint = 'invalid_nested_constraint'; + case MissingProperty = 'missing_property'; + case Ok = 'ok'; +} diff --git a/src/ConstraintResult.php b/src/ConstraintResult.php new file mode 100644 index 0000000000000000000000000000000000000000..88a3c3d29fc4e6bcbf94cd0913a6e4333019bf68 --- /dev/null +++ b/src/ConstraintResult.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance; + +use Ds\Map; + +readonly class ConstraintResult +{ + /** + * @param array<ConstraintResult> $nested + */ + public function __construct( + public mixed $castedData, + public ConstraintPath $path, + public ConstraintResultStatus $status, + public ConstraintReason $reason, + public array $nested = [], + ) {} + + /** + * @param Map<string,non-empty-string> $errors + * + * @return Map<string,non-empty-string> + */ + public function getErrors(Map $errors = new Map()): Map + { + if ($this->status->isValid()) { + return $errors; + } + + $errors->put((string) $this->path, $this->reason->value); + + foreach ($this->nested as $nestedResult) { + $nestedResult->getErrors($errors); + } + + return $errors; + } +} diff --git a/src/ConstraintResultStatus.php b/src/ConstraintResultStatus.php new file mode 100644 index 0000000000000000000000000000000000000000..bae7fb2e682fe4b40092a5ba789ae3bdfc9fa22d --- /dev/null +++ b/src/ConstraintResultStatus.php @@ -0,0 +1,16 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance; + +enum ConstraintResultStatus +{ + case Invalid; + case Valid; + + public function isValid(): bool + { + return ConstraintResultStatus::Valid === $this; + } +} diff --git a/src/ConstraintStringFormat.php b/src/ConstraintStringFormat.php new file mode 100644 index 0000000000000000000000000000000000000000..4df07ee764075ef6f46d28a9e99115567858dc29 --- /dev/null +++ b/src/ConstraintStringFormat.php @@ -0,0 +1,10 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance; + +enum ConstraintStringFormat +{ + case Uuid; +} diff --git a/src/HttpControllerReflectionMethod.php b/src/HttpControllerReflectionMethod.php index 3a407b9230cf6fe9aecaa5dffdfb0fd49f7f541f..76f3fba19db80c7d021e7f06e921625d04f7eece 100644 --- a/src/HttpControllerReflectionMethod.php +++ b/src/HttpControllerReflectionMethod.php @@ -53,9 +53,13 @@ readonly class HttpControllerReflectionMethod ); } - if ($returnType->isBuiltin() && 'void' !== $returnType->getName()) { + if ($returnType->isBuiltin()) { + if ('void' === $returnType->getName()) { + return; + } + throw new HttpControllerMetadataException( - 'Only supported return type', + 'Only supported builtin return type is "void"', $this->reflectionMethod, ); } @@ -71,7 +75,7 @@ readonly class HttpControllerReflectionMethod throw new HttpControllerMetadataException( sprintf( - 'Controller handle can only return null or %s or %s', + 'Controller handle can only return null or "%s" or "%s"', HttpResponderInterface::class, HttpInterceptableInterface::class, ), diff --git a/src/InputValidationResult.php b/src/InputValidationResult.php index 18b72404ea767008e8f2d3cf0d6a47a453d5c24a..36c9ac51c8ede9a69f7bd25527d172f290a3190a 100644 --- a/src/InputValidationResult.php +++ b/src/InputValidationResult.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Distantmagic\Resonance; use Ds\Map; +use Ds\Set; /** * @template TValidatedModel of InputValidatedData @@ -12,7 +13,7 @@ use Ds\Map; readonly class InputValidationResult { /** - * @var Map<string,string> $errors + * @var Map<non-empty-string,Set<non-empty-string>> $errors */ public Map $errors; diff --git a/src/InputValidatorController.php b/src/InputValidatorController.php index 8793da2b8190917e442e7755fc7b05811af44679..91ed354de016b2d8cfaf7fc1614e3ada44d28420 100644 --- a/src/InputValidatorController.php +++ b/src/InputValidatorController.php @@ -53,7 +53,7 @@ readonly class InputValidatorController foreach ($errors as $propertyName => $propertyErrors) { $validationResult->errors->put( $propertyName, - implode("\n", $propertyErrors), + $propertyErrors, ); } diff --git a/src/JsonSchemaValidationErrorMessage.php b/src/JsonSchemaValidationErrorMessage.php index 994ba6f4b975ba169be6c382d7a869cd94156eda..9f5036246156d9dcdce050d11a96c21640b07cb7 100644 --- a/src/JsonSchemaValidationErrorMessage.php +++ b/src/JsonSchemaValidationErrorMessage.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Distantmagic\Resonance; +use Ds\Set; use Stringable; readonly class JsonSchemaValidationErrorMessage implements Stringable @@ -11,7 +12,7 @@ readonly class JsonSchemaValidationErrorMessage implements Stringable public string $message; /** - * @param array<string,array<string>> $errors + * @param array<non-empty-string,Set<non-empty-string>> $errors */ public function __construct(array $errors) { diff --git a/src/JsonSchemaValidationException.php b/src/JsonSchemaValidationException.php index 3c2be17594ddcf501028f3d6e3c0b8b863bdbbde..9f81f837fcaf66848ea3f0beab8a236f003f1e9d 100644 --- a/src/JsonSchemaValidationException.php +++ b/src/JsonSchemaValidationException.php @@ -4,12 +4,13 @@ declare(strict_types=1); namespace Distantmagic\Resonance; +use Ds\Set; use RuntimeException; class JsonSchemaValidationException extends RuntimeException { /** - * @param array<string,array<string>> $errors + * @param array<non-empty-string,Set<non-empty-string>> $errors */ public function __construct(array $errors) { diff --git a/src/JsonSchemaValidationResult.php b/src/JsonSchemaValidationResult.php index 0120fd6e7189c032698636586dd95bd2e6cda265..db8b14cb002ffa5ce22ecff834796697a09f93b2 100644 --- a/src/JsonSchemaValidationResult.php +++ b/src/JsonSchemaValidationResult.php @@ -4,14 +4,16 @@ declare(strict_types=1); namespace Distantmagic\Resonance; +use Ds\Set; + /** * @template TValidatedData */ readonly class JsonSchemaValidationResult { /** - * @param TValidatedData $data - * @param array<string,array<string>> $errors + * @param TValidatedData $data + * @param array<non-empty-string,Set<non-empty-string>> $errors */ public function __construct( public mixed $data, diff --git a/src/JsonSchemaValidator.php b/src/JsonSchemaValidator.php index 1af23bc770c2fe9f21bd7f054f7ce122c2ca8ed5..713e64a59fdefb78cde177ef06fc594e29bbc0ed 100644 --- a/src/JsonSchemaValidator.php +++ b/src/JsonSchemaValidator.php @@ -6,7 +6,9 @@ namespace Distantmagic\Resonance; use Distantmagic\Resonance\Attribute\Singleton; use Ds\Map; +use Ds\Set; use Opis\JsonSchema\Errors\ErrorFormatter; +use Opis\JsonSchema\Errors\ValidationError; use Opis\JsonSchema\Helper; use Opis\JsonSchema\Parsers\SchemaParser; use Opis\JsonSchema\Schema; @@ -91,7 +93,7 @@ readonly class JsonSchemaValidator } /** - * @return array<string,array<string>> + * @return array<non-empty-string,Set<non-empty-string>> */ private function formatErrors(ValidationResult $validationResult): array { @@ -102,8 +104,74 @@ readonly class JsonSchemaValidator } /** - * @var array<string,array<string>> + * @var list<array{ + * message: non-empty-string, + * keywords: list<non-empty-string>, + * path: non-empty-string, + * }> */ - return $this->errorFormatter->formatKeyed($error); + $nestedFormat = []; + + $this->errorFormatter->formatNested( + error: $error, + formatter: function (ValidationError $validationError) use (&$nestedFormat) { + $fullPath = implode('.', $validationError->data()->fullPath()); + + /** + * @var array<non-empty-string> $keywords + */ + $keywords = [ + $validationError->keyword(), + ]; + + $args = $validationError->args(); + + /** + * @var non-empty-string $keyword + * @var list<string> $paths + */ + foreach ($args as $keyword => $paths) { + $keywords[] = $keyword; + + foreach ($paths as $path) { + if (empty($fullPath)) { + $fullPath = $path; + } else { + throw new RuntimeException('Ambigous error path'); + } + } + } + + if (empty($fullPath)) { + throw new RuntimeException('Unable to determine error path'); + } + + $nestedFormat[] = [ + 'message' => $this->errorFormatter->formatErrorMessage($validationError), + 'keywords' => $keywords, + 'path' => $fullPath, + ]; + }, + ); + + /** + * @var array<non-empty-string,Set<non-empty-string>> + */ + $ret = []; + + foreach ($nestedFormat as $error) { + if (!isset($ret[$error['path']])) { + /** + * @var Set<non-empty-string> + */ + $ret[$error['path']] = new Set(); + } + + foreach ($error['keywords'] as $keyword) { + $ret[$error['path']]->add($keyword); + } + } + + return $ret; } } diff --git a/src/JsonSchemableInterface.php b/src/JsonSchemableInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..7fe349fecc56d93e79ec3a682b478c58c4062f39 --- /dev/null +++ b/src/JsonSchemableInterface.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance; + +use stdClass; + +/** + * @psalm-type PJsonSchema = stdClass|array{ + * const?: int|float|non-empty-string, + * default?: mixed, + * type?: non-empty-string|list<non-empty-string>, + * ... + * } + */ +interface JsonSchemableInterface +{ + /** + * @return PJsonSchema + */ + public function toJsonSchema(): array|object; +}