Skip to content

Commit 2b2851c

Browse files
committed
Add new Schema validation constraint.
This constraint is like an extension of `Json` and `Yaml` constraints. It checks if given value is valid for the given format, then, if you declare `constraints` option, it will try to apply those constraints to the deserialized data.
1 parent cb16097 commit 2b2851c

File tree

4 files changed

+277
-0
lines changed

4 files changed

+277
-0
lines changed
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Validator\Constraints;
13+
14+
use Symfony\Component\Validator\Attribute\HasNamedArguments;
15+
use Symfony\Component\Validator\Constraint;
16+
use Symfony\Component\Validator\Exception\InvalidArgumentException;
17+
use Symfony\Component\Validator\Exception\LogicException;
18+
use Symfony\Component\Yaml\Parser;
19+
20+
/**
21+
* @author Benjamin Georgeault <bgeorgeault@wedgesama.fr>
22+
*/
23+
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
24+
class Schema extends Composite
25+
{
26+
public const JSON = 'JSON';
27+
public const YAML = 'YAML';
28+
29+
public const INVALID_ERROR = 'f8925b90-edfd-4364-a17a-f34a60f24b26';
30+
31+
public string $format;
32+
33+
protected const ERROR_NAMES = [
34+
self::INVALID_ERROR => 'INVALID_ERROR',
35+
];
36+
37+
private static array $allowedTypes = [
38+
self::JSON,
39+
self::YAML,
40+
];
41+
42+
/**
43+
* @param array<Constraint>|Constraint $constraints
44+
*/
45+
#[HasNamedArguments]
46+
public function __construct(
47+
string $format,
48+
public array|Constraint $constraints = [],
49+
public ?string $invalidMessage = 'Cannot apply schema validation, this value does not respect format.',
50+
?array $groups = null,
51+
mixed $payload = null,
52+
public int $flags = 0,
53+
public ?int $depth = null,
54+
) {
55+
$this->format = $format = strtoupper($format);
56+
57+
if (!\in_array($format, static::$allowedTypes)) {
58+
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)));
59+
}
60+
61+
if (self::YAML === $format && !class_exists(Parser::class)) {
62+
throw new LogicException('The Yaml component is required to use the Yaml constraint. Try running "composer require symfony/yaml".');
63+
}
64+
65+
parent::__construct([
66+
'constraints' => $constraints,
67+
], $groups, $payload);
68+
}
69+
70+
protected function getCompositeOption(): string
71+
{
72+
return 'constraints';
73+
}
74+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Validator\Constraints;
13+
14+
use Symfony\Component\Validator\Constraint;
15+
use Symfony\Component\Validator\ConstraintValidator;
16+
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
17+
use Symfony\Component\Validator\Exception\UnexpectedValueException;
18+
use Symfony\Component\Validator\Exception\ValidatorException;
19+
use Symfony\Component\Yaml\Exception\ParseException;
20+
use Symfony\Component\Yaml\Parser;
21+
22+
/**
23+
* @author Benjamin Georgeault <bgeorgeault@wedgesama.fr>
24+
*/
25+
class SchemaValidator extends ConstraintValidator
26+
{
27+
public function validate(mixed $value, Constraint $constraint): void
28+
{
29+
if (!$constraint instanceof Schema) {
30+
throw new UnexpectedTypeException($constraint, Schema::class);
31+
}
32+
33+
if (null === $value || '' === $value) {
34+
return;
35+
}
36+
37+
if (!\is_scalar($value) && !$value instanceof \Stringable) {
38+
throw new UnexpectedValueException($value, 'string');
39+
}
40+
41+
$value = (string) $value;
42+
43+
try {
44+
$data = match ($constraint->format) {
45+
Schema::YAML => $this->validateAndGetYaml($value, $constraint),
46+
Schema::JSON => $this->validateAndGetJson($value, $constraint),
47+
};
48+
} catch (ValidatorException $e) {
49+
$this->context->buildViolation($constraint->invalidMessage)
50+
->setParameter('{{ error }}', $e->getMessage())
51+
->setParameter('{{ format }}', $constraint->format)
52+
->setCode(Schema::INVALID_ERROR)
53+
->addViolation();
54+
55+
return;
56+
}
57+
58+
if (empty($constraint->constraints)) {
59+
return;
60+
}
61+
62+
$validator = ($context = $this->context)
63+
->getValidator()->inContext($context);
64+
65+
$validator->validate($data, $constraint->constraints);
66+
}
67+
68+
private function validateAndGetYaml(string $value, Schema $constraint): mixed
69+
{
70+
try {
71+
return (new Parser())->parse($value, $constraint->flags);
72+
} catch (ParseException $e) {
73+
throw new ValidatorException(\sprintf('Invalid YAML with message "%s".', $e->getMessage()));
74+
} finally {
75+
restore_error_handler();
76+
}
77+
}
78+
79+
private function validateAndGetJson(string $value, Schema $constraint): mixed
80+
{
81+
if (!json_validate($value, $constraint->depth ?? 512, $constraint->flags)) {
82+
throw new ValidatorException('Invalid JSON.');
83+
}
84+
85+
return json_decode($value, true, $constraint->depth ?? 512, $constraint->flags);
86+
}
87+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Validator\Tests\Constraints;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Validator\Constraints\Schema;
16+
17+
class SchemaTest extends TestCase
18+
{
19+
public function testEmptyFieldsInOptions(): void
20+
{
21+
$constraint = new Schema(
22+
format: 'YAML',
23+
invalidMessage: 'fooo',
24+
);
25+
26+
$this->assertSame([], $constraint->constraints);
27+
$this->assertSame('YAML', $constraint->format);
28+
$this->assertSame('fooo', $constraint->invalidMessage);
29+
$this->assertSame(0, $constraint->flags);
30+
}
31+
32+
public function testUpperFormat(): void
33+
{
34+
$constraint = new Schema(
35+
format: 'yaml',
36+
);
37+
38+
$this->assertSame('YAML', $constraint->format);
39+
}
40+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Validator\Tests\Constraints;
13+
14+
use Symfony\Component\Validator\Constraints\NotNull;
15+
use Symfony\Component\Validator\Constraints\Schema;
16+
use Symfony\Component\Validator\Constraints\SchemaValidator;
17+
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
18+
19+
class SchemaValidatorTest extends ConstraintValidatorTestCase
20+
{
21+
protected function createValidator(): SchemaValidator
22+
{
23+
return new SchemaValidator();
24+
}
25+
26+
/**
27+
* @dataProvider getValidValues
28+
*/
29+
public function testFormatIsValid(string $format, string $value): void
30+
{
31+
$this->validator->validate($value, new Schema($format));
32+
33+
$this->assertNoViolation();
34+
}
35+
36+
/**
37+
* @dataProvider getInvalidValues
38+
*/
39+
public function testFormatIsInvalid(string $format, string $value, string $errorMsg): void
40+
{
41+
$this->validator->validate($value, new Schema($format));
42+
43+
$this->buildViolation('Cannot apply schema validation, this value does not respect format.')
44+
->setParameter('{{ error }}', $errorMsg)
45+
->setParameter('{{ format }}', $format)
46+
->setCode(Schema::INVALID_ERROR)
47+
->assertRaised();
48+
}
49+
50+
public function testValidWithConstraint(): void
51+
{
52+
$constraint = new Schema(
53+
format: 'yaml',
54+
constraints: new NotNull(),
55+
);
56+
57+
$this->validator->validate('foo: "bar"', $constraint);
58+
$this->assertNoViolation();
59+
}
60+
61+
public static function getValidValues(): array
62+
{
63+
return [
64+
['yaml', 'foo: "bar"'],
65+
['json', '{"foo":"bar"}'],
66+
];
67+
}
68+
69+
public static function getInvalidValues(): array
70+
{
71+
return [
72+
['YAML', 'foo: ["bar"', 'Invalid YAML with message "Malformed inline YAML string at line 1 (near "foo: ["bar"").".'],
73+
['JSON', '{"foo:"bar"}', 'Invalid JSON.'],
74+
];
75+
}
76+
}

0 commit comments

Comments
 (0)