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

[12.x] Class-based UUID validation rule #53378

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/Illuminate/Validation/Rule.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use Illuminate\Validation\Rules\ProhibitedIf;
use Illuminate\Validation\Rules\RequiredIf;
use Illuminate\Validation\Rules\Unique;
use Illuminate\Validation\Rules\Uuid;

class Rule
{
Expand Down Expand Up @@ -210,4 +211,14 @@ public static function dimensions(array $constraints = [])
{
return new Dimensions($constraints);
}

/**
* Get an uuid rule builder instance.
*
* @return \Illuminate\Validation\Rules\Uuid
*/
public static function uuid()
{
return new Uuid();
}
}
205 changes: 205 additions & 0 deletions src/Illuminate/Validation/Rules/Uuid.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
<?php

namespace Illuminate\Validation\Rules;

use Illuminate\Contracts\Validation\Rule;
use Illuminate\Contracts\Validation\ValidatorAwareRule;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Illuminate\Validation\Validator;
use Ramsey\Uuid\Exception\InvalidUuidStringException;
use Ramsey\Uuid\FeatureSet;
use Ramsey\Uuid\Rfc4122\FieldsInterface;
use Ramsey\Uuid\Rfc4122\UuidV2;
use Ramsey\Uuid\Uuid as UuidUuid;
use Ramsey\Uuid\UuidFactory;
use ReflectionProperty;

class Uuid implements Rule, ValidatorAwareRule
{
/**
* The current validator instance.
*
* @var \Illuminate\Validation\Validator
*/
protected $validator;

/**
* @var int<0, 8>|'max'
*/
protected string|int $version;

/**
* Usually, the MAC address (can be random)
* Supported by v1, v2, and v6.
*/
protected string $node;

/**
* @var ':'|'-'
*/
protected string $separator = ':';

/**
* Usually, the namespace
* Supported by v3 and v5.
*/
protected string $domain;

/**
* Usually, the namespace ID
* Supported by v3 and v5.
*/
protected string $identifier;

/**
* Supported by v1, v2, v6, and v7.
*
* @var callable
*/
protected $dateTimeCallback;

public function passes($attribute, $value)
{
if (! Str::isUuid($value)) {
return false;
}

$factory = new UuidFactory;

try {
$factoryUuid = $factory->fromString($value);
} catch (InvalidUuidStringException $ex) {
return false;
}

$fields = $factoryUuid->getFields();

if (! ($fields instanceof FieldsInterface)) {
return false;
}

foreach (['version', 'node', 'domain', 'identifier', 'dateTimeCallback'] as $prop) {
if (! $this->isInitialized($prop)) {
continue;
}

if (
$prop === 'version' && (
(in_array($this->version, [0, 'nil'], true) && ! $fields->isNil()) ||
($this->version === 'max' && ! $fields->isMax()) ||
(! in_array($this->version, [0, 'nil', 'max']) && $fields->getVersion() !== $this->version)
)
) {
return false;
}

if ($prop === 'node' && in_array($fields->getVersion(), [
UuidUuid::UUID_TYPE_TIME,
UuidUuid::UUID_TYPE_DCE_SECURITY,
UuidUuid::UUID_TYPE_REORDERED_TIME,
]) && $this->node !== $this->formatMacAddress($fields->getNode()->toString())) {
return false;
}

if (
$prop === 'domain' &&
$fields->getVersion() === UuidUuid::UUID_TYPE_DCE_SECURITY &&
$this->toV2($fields, $factory)->getLocalDomainName() !== $this->domain
) {
return false;
}

if (
$prop === 'identifier' &&
$fields->getVersion() === UuidUuid::UUID_TYPE_DCE_SECURITY &&
$this->toV2($fields, $factory)->getLocalIdentifier()->toString() !== $this->identifier
) {
return false;
}

if (
$prop === 'dateTimeCallback' &&
$this->dateTimeCallback !== null &&
in_array($fields->getVersion(), [
UuidUuid::UUID_TYPE_TIME,
UuidUuid::UUID_TYPE_DCE_SECURITY,
UuidUuid::UUID_TYPE_REORDERED_TIME,
UuidUuid::UUID_TYPE_UNIX_TIME,
]) &&
! call_user_func($this->dateTimeCallback, Carbon::createFromId($factoryUuid))
) {
return false;
}
}

return true;
}

private function toV2(FieldsInterface $fields, UuidFactory $factory)
{
$features = new FeatureSet();

return new UuidV2($fields, $factory->getNumberConverter(), $factory->getCodec(), $features->getTimeConverter());
}

private function formatMacAddress(string $macAddress): string
{
return Str::upper(implode($this->separator, str_split($macAddress, 2)));
}

/**
* Get the validation error message.
*
* @return array
*/
public function message()
{
$message = $this->validator->getTranslator()->get('validation.uuid');

return $message === 'validation.uuid'
? ['The selected UUID (:attribute) is invalid.']
: $message;
}

/**
* Set the current validator.
*
* @param \Illuminate\Validation\Validator $validator
* @return $this
*/
public function setValidator($validator)
{
return tap($this, fn () => $this->validator = $validator);
}

public function version(string|int $version)
{
return tap($this, fn () => $this->version = $version);
}

public function node(string $node)
{
return tap($this, fn () => $this->node = Str::upper($node));
}

public function domain(string|int $domain)
{
return tap($this, fn () => $this->domain = is_int($domain) ? UuidUuid::DCE_DOMAIN_NAMES[$domain] : $domain);
}

public function identifier(string|int $identifier)
{
return tap($this, fn () => $this->identifier = (string) $identifier);
}

public function dateTime(callable $callback)
{
return tap($this, fn () => $this->dateTimeCallback = $callback);
}

private function isInitialized(string $property)
{
return (new ReflectionProperty($this, $property))->isInitialized($this);
}
}
98 changes: 98 additions & 0 deletions tests/Validation/ValidationUuidRuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

namespace Illuminate\Tests\Validation;

use Illuminate\Container\Container;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Facade;
use Illuminate\Translation\ArrayLoader;
use Illuminate\Translation\Translator;
use Illuminate\Validation\Rules\Uuid;
use Illuminate\Validation\ValidationServiceProvider;
use Illuminate\Validation\Validator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Ramsey\Uuid\Uuid as UuidUuid;

class ValidationUuidRuleTest extends TestCase
{
#[DataProvider('providesValidValidationDataAndRules')]
public function testValidationPassesWhenPassingCorrectUuid(string $value, Uuid $rule)
{
$v = new Validator(
resolve('translator'),
[
'uuid' => $value,
],
[
'uuid' => $rule,
]
);

$this->assertFalse($v->fails());
}

public static function providesValidValidationDataAndRules(): array
{
return [
['00000000-0000-0000-0000-000000000000', new Uuid],
['145a1e72-d11d-11e8-a8d5-f2801f1b9fd1', (new Uuid())->version(1)],
['85962c6a-98a5-11ef-8d2a-0242fe50b4b4', (new Uuid())->version(1)->node('02:42:fe:50:b4:b4')],
['000001f5-5e9a-21ea-9e00-0242ac130003', (new Uuid())->version(2)->domain(UuidUuid::DCE_DOMAIN_PERSON)->identifier(501)],
['85962c6a-98a5-11ef-8d2a-0242fe50b4b4', (new Uuid())->version(1)->dateTime(fn (Carbon $dateTime) => $dateTime->isSameDay('2024-11-01'))],
];
}

#[DataProvider('providesInvalidValidationDataAndRules')]
public function testValidationPassesWhenPassingIncorrectUuid(string $value, Uuid $rule)
{
$v = new Validator(
resolve('translator'),
[
'uuid' => $value,
],
[
'uuid' => $rule,
]
);

$this->assertTrue($v->fails());
}

public static function providesInvalidValidationDataAndRules(): array
{
return [
['ZZZZZZZZ-ZZZZ-ZZZZ-ZZZZ-ZZZZZZZZZZZZ', new Uuid],
['145a1e72-d11d-11e8-a8d5-f2801f1b9fd1', (new Uuid())->version(2)],
['145a1e72-d11d-11e8-a8d5-f2801f1b9fd1', (new Uuid())->version(42)],
['85962c6a-98a5-11ef-8d2a-0242fe50b4b4', (new Uuid())->version(1)->node('ZZ:ZZ:ZZ:ZZ:ZZ:ZZ')],
['000001f5-5e9a-21ea-9e00-0242ac130003', (new Uuid())->version(2)->domain(UuidUuid::DCE_DOMAIN_GROUP)->identifier(501)],
['000001f5-5e9a-21ea-9e00-0242ac130003', (new Uuid())->version(2)->domain(UuidUuid::DCE_DOMAIN_PERSON)->identifier(42)],
['85962c6a-98a5-11ef-8d2a-0242fe50b4b4', (new Uuid())->version(1)->dateTime(fn (Carbon $dateTime) => $dateTime->isSameDay('2042-01-11'))],
];
}

protected function setUp(): void
{
$container = Container::getInstance();

$container->bind('translator', function () {
return new Translator(
new ArrayLoader, 'en'
);
});

Facade::setFacadeApplication($container);

(new ValidationServiceProvider($container))->register();
}

protected function tearDown(): void
{
Container::setInstance(null);

Facade::clearResolvedInstances();

Facade::setFacadeApplication(null);
}
}