Skip to content

Commit

Permalink
feature #53154 [Validator] Add the Charset constraint (alexandre-da…
Browse files Browse the repository at this point in the history
…ubois)

This PR was merged into the 7.1 branch.

Discussion
----------

[Validator] Add the `Charset` constraint

| Q             | A
| ------------- | ---
| Branch?       | 7.1
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Issues        | -
| License       | MIT

Our use case: we receive some file contents in our DTOs that we only want to process if their encoding matches UTF-8 and reject the whole thing at validation otherwise.

Commits
-------

e084246 [Validator] Add the `Charset` constraint
  • Loading branch information
nicolas-grekas committed Dec 27, 2023
2 parents ba209bc + e084246 commit 8499c81
Show file tree
Hide file tree
Showing 5 changed files with 241 additions and 0 deletions.
1 change: 1 addition & 0 deletions src/Symfony/Component/Validator/CHANGELOG.md
Expand Up @@ -5,6 +5,7 @@ CHANGELOG
---

* Add `list` and `associative_array` types to `Type` constraint
* Add the `Charset` constraint

7.0
---
Expand Down
43 changes: 43 additions & 0 deletions src/Symfony/Component/Validator/Constraints/Charset.php
@@ -0,0 +1,43 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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\Exception\ConstraintDefinitionException;

/**
* @author Alexandre Daubois <alex.daubois@gmail.com>
*/
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
final class Charset extends Constraint
{
public const BAD_ENCODING_ERROR = '94c5e58b-f892-4e25-8fd6-9d89c80bfe81';

protected const ERROR_NAMES = [
self::BAD_ENCODING_ERROR => 'BAD_ENCODING_ERROR',
];

public array|string $encodings = [];
public string $message = 'The detected encoding "{{ detected }}" does not match one of the accepted encoding: "{{ encodings }}".';

public function __construct(array|string $encodings = null, string $message = null, array $groups = null, mixed $payload = null, array $options = null)
{
parent::__construct($options, $groups, $payload);

$this->message = $message ?? $this->message;
$this->encodings = (array) ($encodings ?? $this->encodings);

if ([] === $this->encodings) {
throw new ConstraintDefinitionException(sprintf('The "%s" constraint requires at least one encoding.', static::class));
}
}
}
46 changes: 46 additions & 0 deletions src/Symfony/Component/Validator/Constraints/CharsetValidator.php
@@ -0,0 +1,46 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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;

/**
* @author Alexandre Daubois <alex.daubois@gmail.com>
*/
final class CharsetValidator extends ConstraintValidator
{
public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof Charset) {
throw new UnexpectedTypeException($constraint, Charset::class);
}

if (null === $value) {
return;
}

if (!\is_string($value)) {
throw new UnexpectedValueException($value, 'string');
}

if (!\in_array($detected = mb_detect_encoding($value, $constraint->encodings, true), $constraint->encodings, true)) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ detected }}', $detected)
->setParameter('{{ encodings }}', implode('", "', $constraint->encodings))
->setCode(Charset::BAD_ENCODING_ERROR)
->addViolation();
}
}
}
65 changes: 65 additions & 0 deletions src/Symfony/Component/Validator/Tests/Constraints/CharsetTest.php
@@ -0,0 +1,65 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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\Charset;
use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
use Symfony\Component\Validator\Mapping\ClassMetadata;
use Symfony\Component\Validator\Mapping\Loader\AttributeLoader;

class CharsetTest extends TestCase
{
public function testSingleEncodingCanBeSet()
{
$encoding = new Charset('UTF-8');

$this->assertSame(['UTF-8'], $encoding->encodings);
}

public function testMultipleEncodingCanBeSet()
{
$encoding = new Charset(['ASCII', 'UTF-8']);

$this->assertSame(['ASCII', 'UTF-8'], $encoding->encodings);
}

public function testThrowsOnNoCharset()
{
$this->expectException(ConstraintDefinitionException::class);
$this->expectExceptionMessage('The "Symfony\Component\Validator\Constraints\Charset" constraint requires at least one encoding.');

new Charset();
}

public function testAttributes()
{
$metadata = new ClassMetadata(CharsetDummy::class);
$loader = new AttributeLoader();
$this->assertTrue($loader->loadClassMetadata($metadata));

[$aConstraint] = $metadata->properties['a']->getConstraints();
$this->assertSame(['UTF-8'], $aConstraint->encodings);

[$bConstraint] = $metadata->properties['b']->getConstraints();
$this->assertSame(['ASCII', 'UTF-8'], $bConstraint->encodings);
}
}

class CharsetDummy
{
#[Charset('UTF-8')]
private string $a;

#[Charset(['ASCII', 'UTF-8'])]
private string $b;
}
@@ -0,0 +1,86 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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\Charset;
use Symfony\Component\Validator\Constraints\CharsetValidator;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;

class CharsetValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator(): CharsetValidator
{
return new CharsetValidator();
}

/**
* @dataProvider provideValidValues
*/
public function testEncodingIsValid(string $value, array $encodings)
{
$this->validator->validate($value, new Charset(encodings: $encodings));

$this->assertNoViolation();
}

/**
* @dataProvider provideInvalidValues
*/
public function testInvalidValues(string $value, array $encodings)
{
$this->validator->validate($value, new Charset(encodings: $encodings));

$this->buildViolation('The detected encoding "{{ detected }}" does not match one of the accepted encoding: "{{ encodings }}".')
->setParameter('{{ detected }}', mb_detect_encoding($value, $encodings, true))
->setParameter('{{ encodings }}', implode(', ', $encodings))
->setCode(Charset::BAD_ENCODING_ERROR)
->assertRaised();
}

/**
* @dataProvider provideInvalidTypes
*/
public function testNonStringValues(mixed $value)
{
$this->expectException(UnexpectedValueException::class);
$this->expectExceptionMessageMatches('/Expected argument of type "string", ".*" given/');

$this->validator->validate($value, new Charset(encodings: ['UTF-8']));
}

public static function provideValidValues()
{
yield ['my ascii string', ['ASCII']];
yield ['my ascii string', ['UTF-8']];
yield ['my ascii string', ['ASCII', 'UTF-8']];
yield ['my ûtf 8', ['ASCII', 'UTF-8']];
yield ['my ûtf 8', ['UTF-8']];
yield ['ώ', ['UTF-16']];
}

public static function provideInvalidValues()
{
yield ['my non-Äscîi string', ['ASCII']];
yield ['😊', ['7bit']];
}

public static function provideInvalidTypes()
{
yield [true];
yield [false];
yield [1];
yield [1.1];
yield [[]];
yield [new \stdClass()];
}
}

0 comments on commit 8499c81

Please sign in to comment.