Skip to content
This repository was archived by the owner on Mar 13, 2025. It is now read-only.

Commit e77ca21

Browse files
committed
feat: added Url rule
1 parent 6811ce0 commit e77ca21

File tree

3 files changed

+145
-0
lines changed

3 files changed

+145
-0
lines changed

src/Exception/UrlException.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<?php
2+
3+
namespace ProgrammatorDev\YetAnotherPhpValidator\Exception;
4+
5+
class UrlException extends ValidationException {}

src/Rule/Url.php

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
namespace ProgrammatorDev\YetAnotherPhpValidator\Rule;
4+
5+
use ProgrammatorDev\YetAnotherPhpValidator\Exception\UnexpectedTypeException;
6+
use ProgrammatorDev\YetAnotherPhpValidator\Exception\UrlException;
7+
8+
class Url extends AbstractRule implements RuleInterface
9+
{
10+
// https://github.com/symfony/validator/blob/7.0/Constraints/UrlValidator.php
11+
private const PATTERN = '~^
12+
(%s):// # protocol
13+
(((?:[\_\.\pL\pN-]|%%[0-9A-Fa-f]{2})+:)?((?:[\_\.\pL\pN-]|%%[0-9A-Fa-f]{2})+)@)? # basic auth
14+
(
15+
(?:
16+
(?:xn--[a-z0-9-]++\.)*+xn--[a-z0-9-]++ # a domain name using punycode
17+
|
18+
(?:[\pL\pN\pS\pM\-\_]++\.)+[\pL\pN\pM]++ # a multi-level domain name
19+
|
20+
[a-z0-9\-\_]++ # a single-level domain name
21+
)\.?
22+
| # or
23+
\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3} # an IP address
24+
| # or
25+
\[
26+
(?:(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){6})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:::(?:(?:(?:[0-9a-f]{1,4})):){5})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){4})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,1}(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){3})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,2}(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){2})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,3}(?:(?:[0-9a-f]{1,4})))?::(?:(?:[0-9a-f]{1,4})):)(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,4}(?:(?:[0-9a-f]{1,4})))?::)(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,5}(?:(?:[0-9a-f]{1,4})))?::)(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,6}(?:(?:[0-9a-f]{1,4})))?::))))
27+
\] # an IPv6 address
28+
)
29+
(:[0-9]+)? # a port (optional)
30+
(?:/ (?:[\pL\pN\-._\~!$&\'()*+,;=:@]|%%[0-9A-Fa-f]{2})* )* # a path
31+
(?:\? (?:[\pL\pN\-._\~!$&\'\[\]()*+,;=:@/?]|%%[0-9A-Fa-f]{2})* )? # a query (optional)
32+
(?:\# (?:[\pL\pN\-._\~!$&\'()*+,;=:@/?]|%%[0-9A-Fa-f]{2})* )? # a fragment (optional)
33+
$~ixu';
34+
35+
// Using array to bypass unallowed callable type in properties
36+
private array $normalizer;
37+
38+
public function __construct(
39+
private readonly array $protocols = ['http', 'https'],
40+
private readonly bool $allowRelativeProtocol = false,
41+
?callable $normalizer = null,
42+
private readonly string $message = 'The {{ name }} value is not a valid URL address, {{ value }} given.'
43+
)
44+
{
45+
$this->normalizer['callable'] = $normalizer;
46+
}
47+
48+
public function assert(mixed $value, ?string $name = null): void
49+
{
50+
if (!\is_string($value)) {
51+
throw new UnexpectedTypeException('string', get_debug_type($value));
52+
}
53+
54+
if ($this->normalizer['callable'] !== null) {
55+
$value = ($this->normalizer['callable'])($value);
56+
}
57+
58+
$pattern = $this->allowRelativeProtocol ? \str_replace('(%s):', '(?:(%s):)?', self::PATTERN) : self::PATTERN;
59+
$pattern = \sprintf($pattern, \implode('|', $this->protocols));
60+
61+
if (!\preg_match($pattern, $value)) {
62+
throw new UrlException(
63+
message: $this->message,
64+
parameters: [
65+
'value' => $value,
66+
'name' => $name,
67+
'protocols' => $this->protocols
68+
]
69+
);
70+
}
71+
}
72+
}

tests/UrlTest.php

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?php
2+
3+
namespace ProgrammatorDev\YetAnotherPhpValidator\Test;
4+
5+
use ProgrammatorDev\YetAnotherPhpValidator\Exception\UrlException;
6+
use ProgrammatorDev\YetAnotherPhpValidator\Rule\Url;
7+
use ProgrammatorDev\YetAnotherPhpValidator\Test\Util\TestRuleFailureConditionTrait;
8+
use ProgrammatorDev\YetAnotherPhpValidator\Test\Util\TestRuleMessageOptionTrait;
9+
use ProgrammatorDev\YetAnotherPhpValidator\Test\Util\TestRuleSuccessConditionTrait;
10+
use ProgrammatorDev\YetAnotherPhpValidator\Test\Util\TestRuleUnexpectedValueTrait;
11+
12+
class UrlTest extends AbstractTest
13+
{
14+
use TestRuleUnexpectedValueTrait;
15+
use TestRuleFailureConditionTrait;
16+
use TestRuleSuccessConditionTrait;
17+
use TestRuleMessageOptionTrait;
18+
19+
public static function provideRuleUnexpectedValueData(): \Generator
20+
{
21+
$typeMessage = '/Expected value of type "string", "(.*)" given./';
22+
23+
yield 'invalid type' => [new Url(), 1, $typeMessage];
24+
}
25+
26+
public static function provideRuleFailureConditionData(): \Generator
27+
{
28+
$exception = UrlException::class;
29+
$message = '/The (.*) value is not a valid URL address, (.*) given./';
30+
31+
yield 'invalid url' => [new URL(), 'invalid', $exception, $message];
32+
yield 'unallowed protocol' => [new URL(protocols: ['https']), 'http://test.com', $exception, $message];
33+
yield 'unallowed relative protocol' => [new URL(), '//test.com', $exception, $message];
34+
}
35+
36+
public static function provideRuleSuccessConditionData(): \Generator
37+
{
38+
yield 'domain' => [new URL(), 'https://test.com'];
39+
yield 'multi-level domain' => [new URL(), 'https://multi.level.url.test.com'];
40+
yield 'chars' => [new URL(), 'https://テスト.com'];
41+
yield 'punycode' => [new URL(), 'https://xn--zckzah.com'];
42+
yield 'port' => [new URL(), 'https://test.com:8000'];
43+
yield 'path' => [new URL(), 'https://test.com/path'];
44+
yield 'query' => [new URL(), 'https://test.com?test=1'];
45+
yield 'fragment' => [new URL(), 'https://test.com#test'];
46+
yield 'ipv4' => [new URL(), 'https://127.0.0.1'];
47+
yield 'ipv6' => [new URL(), 'https://[ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff]'];
48+
yield 'basic auth' => [new URL(), 'https://username:password@test.com'];
49+
yield 'full domain' => [new URL(), 'https://username:password@test.com:8000/path?test=1#test'];
50+
yield 'full ipv4' => [new URL(), 'https://username:password@127.0.0.1:8000/path?test=1#test'];
51+
yield 'full ipv6' => [new URL(), 'https://username:password@[ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff]:8000/path?test=1#test'];
52+
yield 'custom protocol' => [new URL(protocols: ['ftp']), 'ftp://test.com'];
53+
yield 'allow relative protocol with protocol' => [new URL(allowRelativeProtocol: true), 'https://test.com'];
54+
yield 'allow relative protocol without protocol' => [new URL(allowRelativeProtocol: true), '//test.com'];
55+
yield 'normalizer' => [new URL(normalizer: 'trim'), 'https://test.com '];
56+
}
57+
58+
public static function provideRuleMessageOptionData(): \Generator
59+
{
60+
yield 'message' => [
61+
new Url(
62+
message: 'The {{ name }} value {{ value }} is not a valid URL address. Allowed protocols: {{ protocols }}.'
63+
),
64+
'invalid',
65+
'The test value "invalid" is not a valid URL address. Allowed protocols: ["http", "https"].'
66+
];
67+
}
68+
}

0 commit comments

Comments
 (0)