Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Image validation rule #670

Merged
merged 24 commits into from Mar 28, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Expand Up @@ -26,7 +26,7 @@ jobs:
phpunit:
uses: yiisoft/actions/.github/workflows/phpunit.yml@master
with:
extensions: intl
extensions: intl,fileinfo
coverage: xdebug
os: >-
['ubuntu-latest', 'windows-latest']
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -4,6 +4,7 @@

- New #665: Add methods `addErrorWithFormatOnly()` and `addErrorWithoutPostProcessing()` to `Result` object (@vjik)
- Enh #668: Clarify psalm types in `Result` (@vjik)
- New #670: Add `Image` validation rule (@vjik)
vjik marked this conversation as resolved.
Show resolved Hide resolved

## 1.2.0 February 21, 2024

Expand Down
1 change: 1 addition & 0 deletions composer-require-checker.json
Expand Up @@ -15,6 +15,7 @@
"Reflection",
"SPL",
"standard",
"fileinfo",
"intl"
],
"scan-files": []
Expand Down
6 changes: 4 additions & 2 deletions composer.json
Expand Up @@ -30,11 +30,12 @@
"php": "^8.0",
"ext-mbstring": "*",
"psr/container": "^1.0|^2.0",
"psr/http-message": "^1.0|^2.0",
"yiisoft/arrays": "^2.1|^3.0",
"yiisoft/translator": "^2.1|^3.0",
"yiisoft/friendly-exception": "^1.0",
"yiisoft/network-utilities": "^1.0",
"yiisoft/strings": "^2.1"
"yiisoft/strings": "^2.1",
"yiisoft/translator": "^2.1|^3.0"
},
"require-dev": {
"jetbrains/phpstorm-attributes": "^1.0",
Expand All @@ -52,6 +53,7 @@
},
"suggest": {
"ext-intl": "Allows using IDN validation for emails",
"ext-fileinfo": "To use image rule",
"yiisoft/di": "To create rule handlers via Yii DI"
},
"autoload": {
Expand Down
182 changes: 182 additions & 0 deletions src/Rule/Image.php
@@ -0,0 +1,182 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Validator\Rule;

use Attribute;
use Closure;
use Yiisoft\Validator\Rule\Trait\SkipOnEmptyTrait;
use Yiisoft\Validator\Rule\Trait\SkipOnErrorTrait;
use Yiisoft\Validator\Rule\Trait\WhenTrait;
use Yiisoft\Validator\RuleWithOptionsInterface;
use Yiisoft\Validator\SkipOnEmptyInterface;
use Yiisoft\Validator\SkipOnErrorInterface;
use Yiisoft\Validator\WhenInterface;

/**
* @see ImageHandler
arogachev marked this conversation as resolved.
Show resolved Hide resolved
*
* @psalm-import-type SkipOnEmptyValue from SkipOnEmptyInterface
* @psalm-import-type WhenType from WhenInterface
*/
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
final class Image implements RuleWithOptionsInterface, SkipOnErrorInterface, WhenInterface, SkipOnEmptyInterface
{
use SkipOnEmptyTrait;
use SkipOnErrorTrait;
use WhenTrait;

/**
* @param bool|callable|null $skipOnEmpty Whether to skip this rule if the value validated is empty.
arogachev marked this conversation as resolved.
Show resolved Hide resolved
* See {@see SkipOnEmptyInterface}.
* @param bool $skipOnError Whether to skip this rule if any of the previous rules gave an error.
* See {@see SkipOnErrorInterface}.
* @param Closure|null $when A callable to define a condition for applying the rule. See {@see WhenInterface}.
*
* @psalm-param SkipOnEmptyValue $skipOnEmpty
* @psalm-param WhenType $when
*/
public function __construct(
private ?int $width = null,
private ?int $height = null,
private ?int $minWidth = null,
private ?int $minHeight = null,
private ?int $maxWidth = null,
private ?int $maxHeight = null,
private string $notImageMessage = 'The value must be an image.',
private string $notExactlyWidthMessage = 'The width of image "{attribute}" must be exactly {exactly, number} {exactly, plural, one{pixel} other{pixels}}.',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validating aspect ratio is quite a popular case too. Can be added iteratively in a separate PR.

vjik marked this conversation as resolved.
Show resolved Hide resolved
private string $notExactlyHeightMessage = 'The height of image "{attribute}" must be exactly {exactly, number} {exactly, plural, one{pixel} other{pixels}}.',
private string $tooSmallWidthMessage = 'The width of image "{attribute}" cannot be smaller than {limit, number} {limit, plural, one{pixel} other{pixels}}.',
private string $tooSmallHeightMessage = 'The height of image "{attribute}" cannot be smaller than {limit, number} {limit, plural, one{pixel} other{pixels}}.',
private string $tooLargeWidthMessage = 'The width of image "{attribute}" cannot be larger than {limit, number} {limit, plural, one{pixel} other{pixels}}.',
private string $tooLargeHeightMessage = 'The height of image "{attribute}" cannot be larger than {limit, number} {limit, plural, one{pixel} other{pixels}}.',
private mixed $skipOnEmpty = null,
private bool $skipOnError = false,
private Closure|null $when = null,
) {
}

public function getWidth(): ?int
{
return $this->width;
}

public function getHeight(): ?int
{
return $this->height;
}

public function getMinWidth(): ?int
{
return $this->minWidth;
}

public function getMinHeight(): ?int
{
return $this->minHeight;
}

public function getMaxWidth(): ?int
{
return $this->maxWidth;
}

public function getMaxHeight(): ?int
{
return $this->maxHeight;
}

public function getNotImageMessage(): string
{
return $this->notImageMessage;
}

public function getNotExactlyWidthMessage(): string
{
return $this->notExactlyWidthMessage;
}

public function getNotExactlyHeightMessage(): string
{
return $this->notExactlyHeightMessage;
}

public function getTooSmallWidthMessage(): string
{
return $this->tooSmallWidthMessage;
}

public function getTooSmallHeightMessage(): string
{
return $this->tooSmallHeightMessage;
}

public function getTooLargeWidthMessage(): string
{
return $this->tooLargeWidthMessage;
}

public function getTooLargeHeightMessage(): string
{
return $this->tooLargeHeightMessage;
}

public function getName(): string
{
return 'image';
}

public function getHandler(): string
{
return ImageHandler::class;
}

public function getOptions(): array
{
return [
'notExactlyWidthMessage' => [
'template' => $this->notExactlyWidthMessage,
'parameters' => [
'exactly' => $this->width,
],
],
'notExactlyHeightMessage' => [
'template' => $this->notExactlyHeightMessage,
'parameters' => [
'exactly' => $this->height,
],
],
'tooSmallWidthMessage' => [
'template' => $this->tooSmallWidthMessage,
'parameters' => [
'limit' => $this->minWidth,
],
],
'tooSmallHeightMessage' => [
'template' => $this->tooSmallHeightMessage,
'parameters' => [
'limit' => $this->minHeight,
],
],
'tooLargeWidthMessage' => [
'template' => $this->tooLargeWidthMessage,
'parameters' => [
'limit' => $this->maxWidth,
],
],
'tooLargeHeightMessage' => [
'template' => $this->tooLargeHeightMessage,
'parameters' => [
'limit' => $this->maxHeight,
],
],
'notImageMessage' => [
'template' => $this->notImageMessage,
'parameters' => [],
],
'skipOnEmpty' => $this->getSkipOnEmptyOption(),
'skipOnError' => $this->skipOnError,
];
}
}
112 changes: 112 additions & 0 deletions src/Rule/ImageHandler.php
@@ -0,0 +1,112 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Validator\Rule;

use Psr\Http\Message\UploadedFileInterface;
use Yiisoft\Validator\Exception\UnexpectedRuleException;
use Yiisoft\Validator\Result;
use Yiisoft\Validator\RuleHandlerInterface;
use Yiisoft\Validator\ValidationContext;

final class ImageHandler implements RuleHandlerInterface
arogachev marked this conversation as resolved.
Show resolved Hide resolved
{
public function validate(mixed $value, object $rule, ValidationContext $context): Result
{
if (!$rule instanceof Image) {
throw new UnexpectedRuleException(Image::class, $rule);
}

$result = new Result();

$info = $this->getImageInfo($value);
if (empty($info)) {
$result->addError($rule->getNotImageMessage(), ['attribute' => $context->getTranslatedAttribute()]);
return $result;
}

[$width, $height] = $info;

if ($rule->getWidth() !== null && $width !== $rule->getWidth()) {
$result->addError($rule->getNotExactlyWidthMessage(), [
'attribute' => $context->getTranslatedAttribute(),
'exactly' => $rule->getWidth(),
]);
}
if ($rule->getHeight() !== null && $height !== $rule->getHeight()) {
$result->addError($rule->getNotExactlyHeightMessage(), [
'attribute' => $context->getTranslatedAttribute(),
'exactly' => $rule->getHeight(),
]);
}
if ($rule->getMinWidth() !== null && $width < $rule->getMinWidth()) {
$result->addError($rule->getTooSmallWidthMessage(), [
'attribute' => $context->getTranslatedAttribute(),
'limit' => $rule->getMinWidth(),
]);
}
if ($rule->getMinHeight() !== null && $height < $rule->getMinHeight()) {
$result->addError($rule->getTooSmallHeightMessage(), [
'attribute' => $context->getTranslatedAttribute(),
'limit' => $rule->getMinHeight(),
]);
}
if ($rule->getMaxWidth() !== null && $width > $rule->getMaxWidth()) {
$result->addError($rule->getTooLargeWidthMessage(), [
'attribute' => $context->getTranslatedAttribute(),
'limit' => $rule->getMaxWidth(),
]);
}
if ($rule->getMaxHeight() !== null && $height > $rule->getMaxHeight()) {
$result->addError($rule->getTooLargeHeightMessage(), [
'attribute' => $context->getTranslatedAttribute(),
'limit' => $rule->getMaxHeight(),
]);
}

return $result;
}

/**
* @psalm-return array{0:int,1:int}&array
*/
private function getImageInfo(mixed $value): ?array
{
$filePath = $this->getFilePath($value);
if (empty($filePath)) {
return null;
}

if (!$this->isImageFile($filePath)) {
return null;
}

/**
* @psalm-var (array{0:int,1:int}&array)|null $info Need for PHP 8.0 only
*/
$info = getimagesize($filePath);
arogachev marked this conversation as resolved.
Show resolved Hide resolved
return is_array($info) ? $info : null;
}

/**
* From PHP documentation: do not use `getimagesize()` to check that a given file is a valid image. Use
* a purpose-built solution such as the `Fileinfo` extension instead.
*
* @link https://www.php.net/manual/function.getimagesize.php
* @link https://www.php.net/manual/function.mime-content-type.php
*/
private function isImageFile(string $filePath): bool
{
$mimeType = @mime_content_type($filePath);
return $mimeType !== false && str_starts_with($mimeType, 'image/');
}

private function getFilePath(mixed $value): ?string
{
if ($value instanceof UploadedFileInterface) {
$value = $value->getError() === UPLOAD_ERR_OK ? $value->getStream()->getMetadata('uri') : null;
}
return is_string($value) ? $value : null;
}
}
2 changes: 1 addition & 1 deletion tests/Rule/Base/RuleTestCase.php
Expand Up @@ -15,7 +15,7 @@ abstract public function dataValidationPassed(): array;
/**
* @dataProvider dataValidationPassed
*/
public function testValidationPassed(mixed $data, ?array $rules = null): void
public function testValidationPassed(mixed $data, array|RuleInterface|null $rules = null): void
{
$result = (new Validator())->validate($data, $rules);

Expand Down
2 changes: 1 addition & 1 deletion tests/Rule/CompareTest.php
Expand Up @@ -543,7 +543,7 @@ public function hasAttribute(string $attribute): bool
* @dataProvider dataValidationPassed
* @dataProvider dataValidationPassedWithDifferentTypes
*/
public function testValidationPassed(mixed $data, ?array $rules = null): void
public function testValidationPassed(mixed $data, array|RuleInterface|null $rules = null): void
{
parent::testValidationPassed($data, $rules);
}
Expand Down
Binary file added tests/Rule/Image/16x18.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/Rule/Image/16x18.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.