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);