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

Commit 403c5a9

Browse files
authored
Merge pull request #47 from programmatordev/YAPV-21-create-url-rule
Create URL rule
2 parents 6811ce0 + 47dc2aa commit 403c5a9

File tree

7 files changed

+236
-0
lines changed

7 files changed

+236
-0
lines changed

docs/03-rules.md

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
## String Rules
1616

1717
- [Email](03x-rules-email.md)
18+
- [URL](03x-rules-url.md)
1819

1920
## Comparison Rules
2021

docs/03x-rules-url.md

+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# URL
2+
3+
Validates that a value is a valid URL address.
4+
5+
```php
6+
Url(
7+
array $protocols = ['http', 'https'],
8+
bool $allowRelativeProtocol = false,
9+
?callable $normalizer = null,
10+
string $message = 'The {{ name }} value is not a valid URL address, {{ value }} given.'
11+
);
12+
```
13+
14+
## Basic Usage
15+
16+
```php
17+
Validator::url()->validate('https://example.com'); // true
18+
19+
// Only allow the https protocol
20+
Validator::url(protocols: ['https'])->validate('http://example.com'); // false
21+
// Or allow the ftp protocol too
22+
Validator::url(protocols: ['https', 'ftp'])->validate('ftp://example.com'); // true
23+
24+
// Allow relative protocol
25+
Validator::url()->validate('//example.com'); // false
26+
Validator::url(allowRelativeProtocol: true)->validate('//example.com'); // true
27+
Validator::url(allowRelativeProtocol: true)->validate('https://example.com'); // true
28+
```
29+
30+
## Options
31+
32+
### `protocols`
33+
34+
type: `array` default: `['http', 'https']`
35+
36+
Set this option to define the allowed protocols.
37+
38+
### `allowRelativeProtocol`
39+
40+
type: `bool` default: `false`
41+
42+
If this option is `true`, inclusion of a protocol in the URL will be optional.
43+
44+
### `normalizer`
45+
46+
type: `callable` default: `null`
47+
48+
Allows to define a `callable` that will be applied to the value before checking if it is valid.
49+
50+
For example, use `trim`, or pass your own function, to ignore whitespace in the beginning or end of a URL address:
51+
52+
```php
53+
Validator::url()->validate('https://example.com '); // false
54+
55+
Validator::url(normalizer: 'trim')->validate('https://example.com '); // true
56+
Validator::url(normalizer: fn($value) => trim($value))->validate('https://example.com '); // true
57+
```
58+
59+
### `message`
60+
61+
type `string` default: `The {{ name }} value is not a valid URL address, {{ value }} given.`
62+
63+
Message that will be shown if the input value is not a valid URL address.
64+
65+
The following parameters are available:
66+
67+
| Parameter | Description |
68+
|-------------------|---------------------------|
69+
| `{{ value }}` | The current invalid value |
70+
| `{{ name }}` | Name of the invalid value |
71+
| `{{ protocols }}` | Allowed protocols |
72+
73+
## Changelog
74+
75+
- `0.6.0` Created

src/ChainedValidatorInterface.php

+7
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,11 @@ public function type(
8484
string|array $constraint,
8585
string $message = 'The {{ name }} value should be of type {{ constraint }}, {{ value }} given.'
8686
): ChainedValidatorInterface&Validator;
87+
88+
public function url(
89+
array $protocols = ['http', 'https'],
90+
bool $allowRelativeProtocol = false,
91+
?callable $normalizer = null,
92+
string $message = 'The {{ name }} value is not a valid URL address, {{ value }} given.'
93+
): ChainedValidatorInterface&Validator;
8794
}

src/Exception/UrlException.php

+5
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

+72
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+
}

src/StaticValidatorInterface.php

+7
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,11 @@ public static function type(
8383
string|array $constraint,
8484
string $message = 'The {{ name }} value should be of type {{ constraint }}, {{ value }} given.'
8585
): ChainedValidatorInterface&Validator;
86+
87+
public static function url(
88+
array $protocols = ['http', 'https'],
89+
bool $allowRelativeProtocol = false,
90+
?callable $normalizer = null,
91+
string $message = 'The {{ name }} value is not a valid URL address, {{ value }} given.'
92+
): ChainedValidatorInterface&Validator;
8693
}

tests/UrlTest.php

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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:[email protected]'];
49+
yield 'full domain' => [new URL(), 'https://username:[email protected]:8000/path?test=1#test'];
50+
yield 'full ipv4' => [new URL(), 'https://username:[email protected]: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 'allow relative protocol only' => [new URL(protocols: [], allowRelativeProtocol: true), '//test.com'];
56+
yield 'normalizer' => [new URL(normalizer: 'trim'), 'https://test.com '];
57+
}
58+
59+
public static function provideRuleMessageOptionData(): \Generator
60+
{
61+
yield 'message' => [
62+
new Url(
63+
message: 'The {{ name }} value {{ value }} is not a valid URL address. Allowed protocols: {{ protocols }}.'
64+
),
65+
'invalid',
66+
'The test value "invalid" is not a valid URL address. Allowed protocols: ["http", "https"].'
67+
];
68+
}
69+
}

0 commit comments

Comments
 (0)