diff --git a/src/Symfony/Component/Validator/Constraints/Schema.php b/src/Symfony/Component/Validator/Constraints/Schema.php new file mode 100644 index 0000000000000..2d51ba61146d6 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/Schema.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Constraints; + +use Symfony\Component\Validator\Attribute\HasNamedArguments; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Exception\InvalidArgumentException; +use Symfony\Component\Validator\Exception\LogicException; +use Symfony\Component\Yaml\Parser; + +/** + * @author Benjamin Georgeault + */ +#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +class Schema extends Composite +{ + public const JSON = 'JSON'; + public const YAML = 'YAML'; + + public const INVALID_ERROR = 'f8925b90-edfd-4364-a17a-f34a60f24b26'; + + public string $format; + + protected const ERROR_NAMES = [ + self::INVALID_ERROR => 'INVALID_ERROR', + ]; + + private static array $allowedTypes = [ + self::JSON, + self::YAML, + ]; + + /** + * @param array|Constraint $constraints + */ + #[HasNamedArguments] + public function __construct( + string $format, + public array|Constraint $constraints = [], + public ?string $invalidMessage = 'Cannot apply schema validation, this value does not respect format.', + ?array $groups = null, + mixed $payload = null, + public int $flags = 0, + public ?int $depth = null, + ) { + $this->format = $format = strtoupper($format); + + if (!\in_array($format, static::$allowedTypes)) { + throw new InvalidArgumentException(\sprintf('The "format" parameter value is not valid. It must contain one or more of the following values: "%s".', implode(', ', self::$allowedTypes))); + } + + if (self::YAML === $format && !class_exists(Parser::class)) { + throw new LogicException('The Yaml component is required to use the Yaml constraint. Try running "composer require symfony/yaml".'); + } + + parent::__construct([ + 'constraints' => $constraints, + ], $groups, $payload); + } + + protected function getCompositeOption(): string + { + return 'constraints'; + } +} diff --git a/src/Symfony/Component/Validator/Constraints/SchemaValidator.php b/src/Symfony/Component/Validator/Constraints/SchemaValidator.php new file mode 100644 index 0000000000000..33fb486aa9301 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/SchemaValidator.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Constraints; + +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; +use Symfony\Component\Validator\Exception\UnexpectedValueException; +use Symfony\Component\Validator\Exception\ValidatorException; +use Symfony\Component\Yaml\Exception\ParseException; +use Symfony\Component\Yaml\Parser; + +/** + * @author Benjamin Georgeault + */ +class SchemaValidator extends ConstraintValidator +{ + public function validate(mixed $value, Constraint $constraint): void + { + if (!$constraint instanceof Schema) { + throw new UnexpectedTypeException($constraint, Schema::class); + } + + if (null === $value || '' === $value) { + return; + } + + if (!\is_scalar($value) && !$value instanceof \Stringable) { + throw new UnexpectedValueException($value, 'string'); + } + + $value = (string) $value; + + try { + $data = match ($constraint->format) { + Schema::YAML => $this->validateAndGetYaml($value, $constraint), + Schema::JSON => $this->validateAndGetJson($value, $constraint), + }; + } catch (ValidatorException $e) { + $this->context->buildViolation($constraint->invalidMessage) + ->setParameter('{{ error }}', $e->getMessage()) + ->setParameter('{{ format }}', $constraint->format) + ->setCode(Schema::INVALID_ERROR) + ->addViolation(); + + return; + } + + if (empty($constraint->constraints)) { + return; + } + + $validator = ($context = $this->context) + ->getValidator()->inContext($context); + + $validator->validate($data, $constraint->constraints); + } + + private function validateAndGetYaml(string $value, Schema $constraint): mixed + { + try { + return (new Parser())->parse($value, $constraint->flags); + } catch (ParseException $e) { + throw new ValidatorException(\sprintf('Invalid YAML with message "%s".', $e->getMessage())); + } finally { + restore_error_handler(); + } + } + + private function validateAndGetJson(string $value, Schema $constraint): mixed + { + if (!json_validate($value, $constraint->depth ?? 512, $constraint->flags)) { + throw new ValidatorException('Invalid JSON.'); + } + + return json_decode($value, true, $constraint->depth ?? 512, $constraint->flags); + } +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/SchemaTest.php b/src/Symfony/Component/Validator/Tests/Constraints/SchemaTest.php new file mode 100644 index 0000000000000..497f909d695ff --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/SchemaTest.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Tests\Constraints; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Validator\Constraints\Schema; + +class SchemaTest extends TestCase +{ + public function testEmptyFieldsInOptions() + { + $constraint = new Schema( + format: 'YAML', + invalidMessage: 'fooo', + ); + + $this->assertSame([], $constraint->constraints); + $this->assertSame('YAML', $constraint->format); + $this->assertSame('fooo', $constraint->invalidMessage); + $this->assertSame(0, $constraint->flags); + } + + public function testUpperFormat() + { + $constraint = new Schema( + format: 'yaml', + ); + + $this->assertSame('YAML', $constraint->format); + } +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/SchemaValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/SchemaValidatorTest.php new file mode 100644 index 0000000000000..e96245a4124c0 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/SchemaValidatorTest.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Tests\Constraints; + +use Symfony\Component\Validator\Constraints\NotNull; +use Symfony\Component\Validator\Constraints\Schema; +use Symfony\Component\Validator\Constraints\SchemaValidator; +use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; + +class SchemaValidatorTest extends ConstraintValidatorTestCase +{ + protected function createValidator(): SchemaValidator + { + return new SchemaValidator(); + } + + /** + * @dataProvider getValidValues + */ + public function testFormatIsValid(string $format, string $value) + { + $this->validator->validate($value, new Schema($format)); + + $this->assertNoViolation(); + } + + /** + * @dataProvider getInvalidValues + */ + public function testFormatIsInvalid(string $format, string $value, string $errorMsg) + { + $this->validator->validate($value, new Schema($format)); + + $this->buildViolation('Cannot apply schema validation, this value does not respect format.') + ->setParameter('{{ error }}', $errorMsg) + ->setParameter('{{ format }}', $format) + ->setCode(Schema::INVALID_ERROR) + ->assertRaised(); + } + + public function testValidWithConstraint() + { + $constraint = new Schema( + format: 'yaml', + constraints: new NotNull(), + ); + + $this->validator->validate('foo: "bar"', $constraint); + $this->assertNoViolation(); + } + + public static function getValidValues(): array + { + return [ + ['yaml', 'foo: "bar"'], + ['json', '{"foo":"bar"}'], + ]; + } + + public static function getInvalidValues(): array + { + return [ + ['YAML', 'foo: ["bar"', 'Invalid YAML with message "Malformed inline YAML string at line 1 (near "foo: ["bar"").".'], + ['JSON', '{"foo:"bar"}', 'Invalid JSON.'], + ]; + } +}