-
Notifications
You must be signed in to change notification settings - Fork 11k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: ✨ Implement class-based UUID validation rule
with more constraints than just the version
- Loading branch information
Showing
3 changed files
with
313 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,203 @@ | ||
<?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 ':'|'-' $separator | ||
*/ | ||
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 $dateTimeCallback | ||
*/ | ||
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |