Skip to content

Commit

Permalink
feat: ✨ Implement class-based UUID validation rule
Browse files Browse the repository at this point in the history
with more constraints than just the version
  • Loading branch information
shaedrich committed Nov 2, 2024
1 parent e4fcc78 commit 9c4ee2b
Show file tree
Hide file tree
Showing 3 changed files with 313 additions and 0 deletions.
12 changes: 12 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,15 @@ public static function dimensions(array $constraints = [])
{
return new Dimensions($constraints);
}

/**
* Get an uuid rule builder instance.
*
* @param class-string $type
* @return \Illuminate\Validation\Rules\Uuid
*/
public static function uuid($type)
{
return new Uuid($type);

Check failure on line 223 in src/Illuminate/Validation/Rule.php

View workflow job for this annotation

GitHub Actions / Source Code

Class Illuminate\Validation\Rules\Uuid does not have a constructor and must be instantiated without any parameters.
}
}
203 changes: 203 additions & 0 deletions src/Illuminate/Validation/Rules/Uuid.php
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);
}
}
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);
}
}

0 comments on commit 9c4ee2b

Please sign in to comment.