diff --git a/README.md b/README.md
index defc679..1741d3a 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,19 @@
A simple package to deal with URLs in your applications.
-Retrieve parts of the URL:
+## Installation
+
+You can install the package via composer:
+
+```bash
+composer require spatie/url
+```
+
+## Usage
+
+### Parse and transform a URL
+
+Retrieve any part of the URL:
```php
use Spatie\Url\Url;
@@ -18,7 +30,10 @@ echo $url->getHost(); // 'spatie.be'
echo $url->getPath(); // '/opensource'
```
-Transform any part of the URL (the `Url` class is immutable):
+Transform any part of the URL:
+
+> **Note**
+> the `Url` class is immutable.
```php
$url = Url::fromString('https://spatie.be/opensource');
@@ -27,6 +42,37 @@ echo $url->withHost('github.com')->withPath('spatie');
// 'https://github.com/spatie'
```
+### Scheme
+
+Transform the URL scheme.
+```php
+$url = Url::fromString('http://spatie.be/opensource');
+
+echo $url->withScheme('https'); // 'https://spatie.be/opensource'
+```
+
+Use a list of allowed schemes.
+
+> **Note**
+> each scheme in the list will be sanitized
+
+```php
+$url = Url::fromString('https://spatie.be/opensource');
+
+echo $url->withAllowedSchemes(['wss'])->withScheme('wss'); // 'wss://spatie.be/opensource'
+```
+
+or pass the list directly to `fromString` as the URL's scheme will be sanitized and validated immediately:
+
+```php
+$url = Url::fromString('https://spatie.be/opensource', [...SchemeValidator::VALID_SCHEMES, 'wss']);
+
+echo $url->withScheme('wss'); // 'wss://spatie.be/opensource'
+```
+
+
+### Query parameters
+
Retrieve and transform query parameters:
```php
@@ -46,6 +92,8 @@ echo $url->withoutQueryParameter('utm_campaign'); // 'https://spatie.be/opensour
echo $url->withQueryParameters(['utm_campaign' => 'packages']); // 'https://spatie.be/opensource?utm_source=github&utm_campaign=packages'
```
+### Path segments
+
Retrieve path segments:
```php
@@ -55,6 +103,8 @@ echo $url->getSegment(1); // 'opensource'
echo $url->getSegment(2); // 'laravel'
```
+### PSR-7 `UriInterface`
+
Implements PSR-7's `UriInterface` interface:
```php
@@ -65,35 +115,19 @@ The [`league/uri`](https://github.com/thephpleague/uri) is a more powerful packa
Spatie is a webdesign agency based in Antwerp, Belgium. You'll find an overview of all our open source projects [on our website](https://spatie.be/opensource).
-## Support us
-
-[
](https://spatie.be/github-ad-click/url)
-
-We invest a lot of resources into creating [best in class open source packages](https://spatie.be/open-source). You can support us by [buying one of our paid products](https://spatie.be/open-source/support-us).
-
-We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. You'll find our address on [our contact page](https://spatie.be/about-us). We publish all received postcards on [our virtual postcard wall](https://spatie.be/open-source/postcards).
-
-## Installation
-
-You can install the package via composer:
+## Testing
```bash
-composer require spatie/url
+composer test
```
-## Usage
-
-Usage is pretty straightforward. Check out the code examples at the top of this readme.
-
-## Changelog
+## Support us
-Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently.
+[
](https://spatie.be/github-ad-click/url)
-## Testing
+We invest a lot of resources into creating [best in class open source packages](https://spatie.be/open-source). You can support us by [buying one of our paid products](https://spatie.be/open-source/support-us).
-```bash
-composer test
-```
+We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. You'll find our address on [our contact page](https://spatie.be/about-us). We publish all received postcards on [our virtual postcard wall](https://spatie.be/open-source/postcards).
## Changelog
diff --git a/src/BaseValidator.php b/src/BaseValidator.php
new file mode 100644
index 0000000..4ff6350
--- /dev/null
+++ b/src/BaseValidator.php
@@ -0,0 +1,9 @@
+ "`{$scheme}`", $allowedSchemes));
+
+ return new static("The scheme `{$scheme}` isn't valid. It should be either {$schemes}.");
}
public static function invalidUrl(string $url): static
diff --git a/src/Scheme.php b/src/Scheme.php
new file mode 100644
index 0000000..fed8723
--- /dev/null
+++ b/src/Scheme.php
@@ -0,0 +1,65 @@
+validator = new SchemeValidator($allowedSchemes);
+
+ $this->setScheme($scheme);
+ }
+
+ protected function validate(string $scheme): void
+ {
+ $this->validator->setScheme($scheme);
+
+ $this->validator->validate();
+ }
+
+ public function getScheme(): string
+ {
+ return $this->scheme;
+ }
+
+ public function setScheme(string $scheme): void
+ {
+ $sanitizedScheme = $this->validator::sanitizeScheme($scheme);
+
+ $this->validate($sanitizedScheme);
+
+ $this->scheme = $sanitizedScheme;
+ }
+
+ public function getAllowedSchemes(): array
+ {
+ return $this->validator->getAllowedSchemes();
+ }
+
+ public function setAllowedSchemes(array $allowedSchemes): void
+ {
+ $this->validator->setAllowedSchemes($allowedSchemes);
+ }
+
+ public function __toString(): string
+ {
+ return $this->getScheme();
+ }
+
+ public function __clone()
+ {
+ $this->validator = clone $this->validator;
+ }
+}
diff --git a/src/SchemeValidator.php b/src/SchemeValidator.php
new file mode 100644
index 0000000..61755df
--- /dev/null
+++ b/src/SchemeValidator.php
@@ -0,0 +1,58 @@
+scheme = null;
+ $this->allowedSchemes = $allowedSchemes ?? self::VALID_SCHEMES;
+ }
+
+ public function validate(): void
+ {
+ // '' aka "no scheme" must always be valid
+ $alwaysAllowedSchemes = [''];
+
+ if (! in_array($this->scheme, [...$this->allowedSchemes, ...$alwaysAllowedSchemes])) {
+ throw InvalidArgument::invalidScheme($this->scheme, $this->allowedSchemes);
+ }
+ }
+
+ public static function sanitizeScheme(string $scheme): string
+ {
+ // TODO: regex to allow correct format according to https://datatracker.ietf.org/doc/html/rfc3986#section-3.1
+ return strtolower($scheme);
+ }
+
+ public function getScheme(): string|null
+ {
+ return $this->scheme;
+ }
+
+ public function setScheme(string $scheme): void
+ {
+ $this->scheme = $scheme;
+ }
+
+ public function getAllowedSchemes(): array|null
+ {
+ return $this->allowedSchemes;
+ }
+
+ public function setAllowedSchemes(array $allowedSchemes): void
+ {
+ $this->allowedSchemes = array_map(
+ fn($scheme) => static::sanitizeScheme($scheme),
+ $allowedSchemes
+ );
+ }
+}
diff --git a/src/Url.php b/src/Url.php
index ed1b2a8..bde5207 100644
--- a/src/Url.php
+++ b/src/Url.php
@@ -11,7 +11,7 @@ class Url implements UriInterface, Stringable
{
use Macroable;
- protected string $scheme = '';
+ protected Scheme $scheme;
protected string $host = '';
@@ -27,10 +27,9 @@ class Url implements UriInterface, Stringable
protected string $fragment = '';
- public const VALID_SCHEMES = ['http', 'https', 'mailto', 'tel'];
-
public function __construct()
{
+ $this->scheme = new Scheme();
$this->query = new QueryParameterBag();
}
@@ -39,23 +38,33 @@ public static function create(): static
return new static();
}
- public static function fromString(string $url): static
+ public static function fromString(string $url, array|null $allowedSchemes = null): static
{
- if (! $parts = parse_url($url)) {
- throw InvalidArgument::invalidUrl($url);
+ $toUrl = new static();
+
+ if($allowedSchemes !== null) {
+ $toUrl = $toUrl->withAllowedSchemes($allowedSchemes);
}
- $url = new static();
- $url->scheme = isset($parts['scheme']) ? $url->sanitizeScheme($parts['scheme']) : '';
- $url->host = $parts['host'] ?? '';
- $url->port = $parts['port'] ?? null;
- $url->user = $parts['user'] ?? '';
- $url->password = $parts['pass'] ?? null;
- $url->path = $parts['path'] ?? '/';
- $url->query = QueryParameterBag::fromString($parts['query'] ?? '');
- $url->fragment = $parts['fragment'] ?? '';
+ return static::make($url, $toUrl);
+ }
- return $url;
+ protected static function make(string $fromUrl, self $toUrl): static
+ {
+ if (! $parts = parse_url($fromUrl)) {
+ throw InvalidArgument::invalidUrl($fromUrl);
+ }
+
+ $toUrl->scheme->setScheme(isset($parts['scheme']) ? $parts['scheme'] : '');
+ $toUrl->host = $parts['host'] ?? '';
+ $toUrl->port = $parts['port'] ?? null;
+ $toUrl->user = $parts['user'] ?? '';
+ $toUrl->password = $parts['pass'] ?? null;
+ $toUrl->path = $parts['path'] ?? '/';
+ $toUrl->query = QueryParameterBag::fromString($parts['query'] ?? '');
+ $toUrl->fragment = $parts['fragment'] ?? '';
+
+ return $toUrl;
}
public function getScheme(): string
@@ -217,20 +226,18 @@ public function withScheme($scheme): static
{
$url = clone $this;
- $url->scheme = $this->sanitizeScheme($scheme);
+ $url->scheme->setScheme($scheme);
return $url;
}
- protected function sanitizeScheme(string $scheme): string
+ public function withAllowedSchemes(array $schemes): static
{
- $scheme = strtolower($scheme);
+ $url = clone $this;
- if (! in_array($scheme, static::VALID_SCHEMES)) {
- throw InvalidArgument::invalidScheme($scheme);
- }
+ $url->scheme->setAllowedSchemes($schemes);
- return $scheme;
+ return $url;
}
public function withUserInfo($user, $password = null): static
@@ -361,5 +368,6 @@ public function __toString(): string
public function __clone()
{
$this->query = clone $this->query;
+ $this->scheme = clone $this->scheme;
}
}
diff --git a/tests/SchemeTest.php b/tests/SchemeTest.php
new file mode 100644
index 0000000..8a978a3
--- /dev/null
+++ b/tests/SchemeTest.php
@@ -0,0 +1,61 @@
+getScheme()->toEqual('');
+ expect($scheme)->getAllowedSchemes()->toEqual(SchemeValidator::VALID_SCHEMES);
+});
+
+it('casts to a string', function () {
+ $scheme = new Scheme();
+
+ $scheme->setAllowedSchemes(['ws', 'wss']);
+ $scheme->setScheme('wss');
+
+ expect((string) $scheme)->toBe('wss');
+});
+
+it('sanitizes the scheme', function () {
+ $scheme = new Scheme();
+
+ $scheme->setScheme('HTTPS');
+
+ expect($scheme)->getScheme()->toEqual('https');
+});
+
+it('validates by default allowed schemes when setting the scheme', function () {
+ $scheme = new Scheme();
+
+ $scheme->setScheme('https');
+
+ expect($scheme)->not()->toThrow(InvalidArgument::class);
+ expect($scheme)->getScheme()->toEqual('https');
+});
+
+it('doesnt validate by default allowed schemes when setting the scheme', function () {
+ $scheme = new Scheme();
+
+ $scheme->setScheme('xss');
+})->throws(InvalidArgument::class, InvalidArgument::invalidScheme('xss', SchemeValidator::VALID_SCHEMES)->getMessage());
+
+it('validates by custom allowed schemes when setting the scheme', function () {
+ $scheme = new Scheme();
+
+ $scheme->setAllowedSchemes(['ws', 'wss']);
+ $scheme->setScheme('wss');
+
+ expect($scheme)->not()->toThrow(InvalidArgument::class);
+ expect($scheme)->getScheme()->toEqual('wss');
+});
+
+it('doesnt validate by custom allowed schemes when setting the scheme', function () {
+ $scheme = new Scheme();
+
+ $scheme->setAllowedSchemes(['ws', 'wss']);
+ $scheme->setScheme('https');
+})->throws(InvalidArgument::class, InvalidArgument::invalidScheme('https', ['ws', 'wss'])->getMessage());
diff --git a/tests/SchemeValidatorTest.php b/tests/SchemeValidatorTest.php
new file mode 100644
index 0000000..ad2c9ac
--- /dev/null
+++ b/tests/SchemeValidatorTest.php
@@ -0,0 +1,61 @@
+getScheme()->toEqual(null);
+ expect($schemeValidator)->getAllowedSchemes()->toEqual(SchemeValidator::VALID_SCHEMES);
+});
+
+it('can get and set the scheme', function () {
+ $schemeValidator = new SchemeValidator();
+
+ $schemeValidator->setScheme('https');
+
+ expect($schemeValidator)->getScheme()->toEqual('https');
+});
+
+it('can get and set the allowed schemes', function () {
+ $schemeValidator = new SchemeValidator();
+
+ $schemeValidator->setAllowedSchemes(['wss']);
+
+ expect($schemeValidator)->getAllowedSchemes()->toEqual(['wss']);
+});
+
+it('validates against the default allowed schemes', function () {
+ $schemeValidator = new SchemeValidator();
+
+ $schemeValidator->setScheme('https');
+
+ expect($schemeValidator)->validate()->not()->toThrow(InvalidArgument::class);
+});
+
+it('does not validate against the default allowed schemes', function () {
+ $schemeValidator = new SchemeValidator();
+
+ $schemeValidator->setScheme('xss');
+
+ expect($schemeValidator)->validate();
+})->expectException(InvalidArgument::class);
+
+it('validates against modified allowed schemes', function () {
+ $schemeValidator = new SchemeValidator();
+
+ $schemeValidator->setScheme('https');
+ $schemeValidator->setAllowedSchemes(['ftp', 'https']);
+
+ expect($schemeValidator)->validate()->not()->toThrow(InvalidArgument::class);
+});
+
+it('does not validate against modified allowed schemes', function () {
+ $schemeValidator = new SchemeValidator();
+
+ $schemeValidator->setScheme('xss');
+ $schemeValidator->setAllowedSchemes(['ftp', 'https']);
+
+ expect($schemeValidator)->validate();
+})->expectException(InvalidArgument::class);
diff --git a/tests/UrlBuildTest.php b/tests/UrlBuildTest.php
index 8c3b993..24bbe3c 100644
--- a/tests/UrlBuildTest.php
+++ b/tests/UrlBuildTest.php
@@ -2,6 +2,7 @@
use Spatie\Url\Exceptions\InvalidArgument;
use Spatie\Url\Url;
+use Spatie\Url\SchemeValidator;
it('can build a url with a host', function () {
$url = Url::create()->withHost('spatie.be');
@@ -42,7 +43,7 @@
it('throws an exception when providing an invalid url scheme', function () {
Url::create()->withScheme('htps');
-})->throws(InvalidArgument::class, InvalidArgument::invalidScheme('htps')->getMessage());
+})->throws(InvalidArgument::class, InvalidArgument::invalidScheme('htps', SchemeValidator::VALID_SCHEMES)->getMessage());
it('can build a url with a user', function () {
diff --git a/tests/UrlParseTest.php b/tests/UrlParseTest.php
index 2fb233d..2e9397f 100644
--- a/tests/UrlParseTest.php
+++ b/tests/UrlParseTest.php
@@ -2,6 +2,7 @@
use Spatie\Url\Exceptions\InvalidArgument;
use Spatie\Url\Url;
+use Spatie\Url\SchemeValidator;
it('can parse a scheme', function () {
$url = Url::fromString('https://spatie.be');
@@ -47,7 +48,7 @@
it('throws an exception if an invalid scheme is provided', function () {
Url::fromString('htps://spatie.be');
-})->throws(InvalidArgument::class, InvalidArgument::invalidScheme('htps')->getMessage());
+})->throws(InvalidArgument::class, InvalidArgument::invalidScheme('htps', SchemeValidator::VALID_SCHEMES)->getMessage());
it('throws an exception if a totally invalid url is provided', function () {
diff --git a/tests/UrlSchemeTest.php b/tests/UrlSchemeTest.php
new file mode 100644
index 0000000..db9b266
--- /dev/null
+++ b/tests/UrlSchemeTest.php
@@ -0,0 +1,55 @@
+not()->toThrow(InvalidArgument::class);
+ expect($url)->getScheme()->toEqual('');
+});
+
+it('allows a scheme against the default scheme validator', function () {
+
+ $url = Url::fromString('https://spatie.be');
+
+ expect($url)->toEqual('https://spatie.be');
+ expect($url)->getScheme()->toEqual('https');
+});
+
+it('does not allow a scheme against the default scheme validator', function () {
+
+ Url::fromString('wss://spatie.be');
+})->throws(InvalidArgument::class);
+
+it('always allows an empty scheme against configured allowed schemes', function () {
+
+ $url = Url::fromString('websocket.io', ['ws', 'wss']);
+
+ expect($url)->not()->toThrow(InvalidArgument::class);
+});
+
+it('allows a scheme against configured allowed schemes', function () {
+
+ $url = Url::fromString('wss://websocket.io', ['ws', 'wss']);
+
+ expect($url)->toEqual('wss://websocket.io');
+ expect($url)->getScheme()->toEqual('wss');
+});
+
+it('allows a scheme against swapped allowed schemes', function () {
+
+ $url = Url::fromString('https://spatie.be')
+ ->withAllowedSchemes(['wss'])
+ ->withScheme('wss');
+
+ expect($url)->toEqual('wss://spatie.be');
+ expect($url)->getScheme()->toEqual('wss');
+});
+
+it('does not allow a scheme against configured allowed schemes', function () {
+
+ Url::fromString('xss://websocket.io', ['ws', 'wss']);
+})->throws(InvalidArgument::class);