├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── UPGRADE-3.0.md ├── composer.json ├── docs ├── Makefile ├── _static │ └── .gitkeep ├── applying-multiple-normalizations.rst ├── conf.py ├── includes │ ├── normalizer │ │ ├── example.rst │ │ ├── introduction.rst │ │ └── normalizations-list.rst │ ├── parser │ │ ├── example.rst │ │ └── introduction.rst │ └── url │ │ ├── example.rst │ │ └── introduction.rst ├── index.rst ├── lossy-normalizations.rst ├── normalizer.rst ├── overview.rst ├── potentially-lossy-normalizations.rst ├── requirements-and-installation.rst ├── semantically-lossless-normalizations.rst └── url.rst ├── phpunit.xml.dist ├── src ├── DefaultPortIdentifier.php ├── Filter.php ├── Host.php ├── Inspector.php ├── Normalizer.php ├── Parser.php ├── Path.php ├── PunycodeEncoder.php ├── ScopeComparer.php ├── Url.php └── UserInfo.php └── tests ├── DefaultPortIdentifierTest.php ├── FilterTest.php ├── HostTest.php ├── InspectorTest.php ├── NormalizerTest.php ├── ParserTest.php ├── PathTest.php ├── PunycodeEncoderTest.php ├── ScopeComparerTest.php ├── UrlTest.php └── UserInfoTest.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /composer.lock 3 | /build 4 | /docs/_build/ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: php 3 | php: 4 | - 7.2 5 | 6 | install: 7 | - composer install --prefer-dist 8 | 9 | script: 10 | - composer cs 11 | - composer test 12 | 13 | cache: 14 | directories: 15 | - $HOME/.composer/cache/files 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Jon Cram 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # URL 2 | 3 | A PSR-7 `UriInterface` for modelling a URL and accessing/modifying components. 4 | 5 | A `Normalizer` for applying a range of semantically-lossless, potentially-lossless or lossy normalizations, 6 | most commonly for comparison. 7 | 8 | ## Replaced! 9 | 10 | This package has been replaced by `webiginition/uri`. You should avoid using this in new projects, and migrate existing projects to the replacement. 11 | 12 | ## Requirements 13 | 14 | - PHP 7.2.0 or greater 15 | - [composer](https://getcomposer.org/) 16 | 17 | ## Upgrading from 2.x to 3.0 18 | 19 | 3.0 is by no means backwards-compatible with 2.x. Read the [upgrade notes][upgrade-2.x-3.0]. 20 | 21 | ## Documentation 22 | 23 | - [Documentation home][documentation-home] 24 | - [Overview][documentation-overview] 25 | - [Requirements and installation][documentation-requirements-and-installation] 26 | - Usage 27 | - [Url Model][documentation-usage-url-model] 28 | - [Normalizer][documentation-usage-normalizer] 29 | - Normalization details 30 | - [Applying multiple normalizations][documentation-applying-multiple-normalizations] 31 | - [Semantically-lossless normalizations][documentation-semantically-lossless-normalizations] 32 | - [Potentially-lossy normalizations][documentation-potentially-lossless-normalizations] 33 | - [Lossy normalizations][documentation-lossy-normalizations] 34 | 35 | [upgrade-2.x-3.0]: https://github.com/webignition/url/blob/master/UPGRADE-3.0.md 36 | [documentation-home]: https://url.webignition.net/en/latest/ 37 | [documentation-overview]: https://url.webignition.net/en/latest/overview.html 38 | [documentation-requirements-and-installation]: https://url.webignition.net/en/latest/requirements-and-installation.html 39 | [documentation-getting-started]: https://url.webignition.net/en/latest/requirements-and-installation.html 40 | [documentation-usage-url-model]: https://url.webignition.net/en/latest/url.html 41 | [documentation-usage-normalizer]: https://url.webignition.net/en/latest/normalizer.html 42 | [documentation-applying-multiple-normalizations]: https://url.webignition.net/en/latest/applying-multiple-normalizations.html 43 | [documentation-semantically-lossless-normalizations]: https://url.webignition.net/en/latest/semantically-lossless-normalizations.html 44 | [documentation-potentially-lossless-normalizations]: https://url.webignition.net/en/latest/potentially-lossy-normalizations.html 45 | [documentation-lossy-normalizations]: https://url.webignition.net/en/latest/lossy-normalizations.html 46 | -------------------------------------------------------------------------------- /UPGRADE-3.0.md: -------------------------------------------------------------------------------- 1 | # Upgrading to 3.0 2 | 3 | ## Overview 4 | 5 | The 3.0 releases introduce significant changes and is in no way backwards-compatible 6 | with 2.x: 7 | 8 | - PHP 7.2+ only 9 | - Url implements [`UriInterface`](https://github.com/php-fig/http-message/blob/master/src/UriInterface.php) 10 | - Remove `NormalisedUrl`, replace with `Normalizer` 11 | - Remove `Query` model 12 | 13 | ## PHP 7.2+ Only 14 | 15 | If you're running a version of PHP older than 7.2, you will have to upgrade. There's no other way. 16 | 17 | ## Url Implements UriInterface 18 | 19 | `Url` now implements the [ PSR-7` UriInterface`](https://github.com/php-fig/http-message/blob/master/src/UriInterface.php). 20 | 21 | This is a significant change that improves interoperability with any other package or library that handles ``UrlInterface`` 22 | instances. 23 | 24 | The most significant change is that a `Url` is now immutable. Methods named `set*()` have been replaced with methods 25 | named `with*()` which return a new instance that has a relevant changed component. 26 | 27 | - constructor behaviour remains the same from an outside perspective 28 | - most `get*()` methods remain in place but may have different return types 29 | - `set*()` methods have changed to `with*()`methods (approximately) 30 | 31 | | 2.x method | 3.0 Method or Equivalent | 32 | |---|---| 33 | | `init()` | No alternative | 34 | | `__construct(string $url)` | `__construct(string $url)` | 35 | | `__toString(): string` | `__toString(): string` | 36 | | `getRoot()` | `$root = $url->getScheme() . '://' . $url->getAuthority()` | 37 | | `getScheme(): string` | `getScheme(): string` | 38 | | `getUser(): string` | `(new UserInfo($url->getUserInfo()))->getUser()` | 39 | | `getPass()` | `(new UserInfo($url->getUserInfo()))->getPassword()` | 40 | | `getHost(): Host` | `getHost(): string` | 41 | | `getPort(): int` | `getPort(): int \| null` | 42 | | `getPath(): Path` | `getPath(): string` | 43 | | `getQuery(): Query` | `getQuery(): string` | 44 | | `getFragment(): string` | `getFragment(): string` | 45 | | `setScheme(string $scheme): bool` | `withScheme(string $scheme): Url` | 46 | | `setUser(string $user): bool` | `withUserInfo(string $user, ?string $password): Url` | 47 | | `setPass(?string $pass): bool` | `withUserInfo(string $user, ?string $password): Url` | 48 | | `setHost(string $host`): bool | `withHost(string $host): Url` | 49 | | `setPort(int \| null $port): bool` | `withPort(int \| null $port): Url` | 50 | | `setPath(string $path): bool` | `withPath(string $path): Url` | 51 | | `setQuery(string $query): bool` | `withQuery(string $query): Url` | 52 | | `setFragment(string $fragment): bool` | `withFragment(string $fragment): Url` | 53 | | `setPart(string $partName, $value): bool` | Use part-appropriate `with*()` | 54 | | `hasScheme(): bool` | `'' !== $url->getScheme()` | 55 | | `hasUser(): bool` | `'' !== $url->getUserInfo()` | 56 | | `hasPass(): bool` | `null !== (new UserInfo($url->getUserInfo()))->getPassword()` | 57 | | `hasCredentials()` | `'' !== $url->getUserInfo()` | 58 | | `hasPort(): bool` | `null !== $url->getPort()` | 59 | | `hasHost(): bool` | `'' !== $url->getHost()` | 60 | | `hasPath(): bool` | `'' !== $url->getPath()` | 61 | | `hasFragment(): bool` | `'' !== $url->getFragment()` | 62 | | `isRelative(): bool` | `(new Path($url->getPath(())->isRelative()` | 63 | | `isAbsolute()` | `(new Path($url->getPath(())->isAbsolute()` | 64 | | `isProtocolRelative(): bool` | `Inspector::isProtocolRelative($url)` | 65 | | `isPubliclyRoutable()` | `Inspector::isPubliclyRoutable($url)` | 66 | | `getConfiguration()` | No alternative | 67 | 68 | ## Remove NormalisedUrl, Replace With Normalizer 69 | 70 | Having both `Url` and `NormalisedUrl` classes has started to make less and less sense. 71 | 72 | The `NormalisedUrl` class has been removed. A `Normalizer` class has been added to provide 73 | equivalent normalization. 74 | 75 | Before: 76 | 77 | ```php 78 | use webignition\NormalisedUrl\NormalisedUrl; 79 | 80 | $normalizedUrl = new NormalisedUrl('http://example.com/?b=bar&a=foo'); 81 | echo (string) $normalizedUrl; 82 | // http://example.com/?a=foo&b=bar 83 | ``` 84 | 85 | After: 86 | 87 | ```php 88 | use webignition\Url\Normalizer; 89 | use webignition\Url\Url; 90 | 91 | $url = new Url('http://example.com/?b=bar&a=foo'); 92 | $normalizedUrl = Normalizer::normalize($url, Normalizer::SORT_QUERY_PARAMETERS); 93 | echo (string) $normalizedUrl; 94 | // http://example.com/?a=foo&b=bar 95 | ``` 96 | 97 | ## Remove Parser Constructor, Remove Parser::getParts() 98 | 99 | That the parser took a URL string as a constructor argument rendered a parser useful for parsing 100 | just a single URL. 101 | 102 | The constructor and the `getParts()` method have been removed. Call `Parser::parse()` instead. 103 | 104 | Before: 105 | 106 | ```php 107 | $parser = new Parser('http://example.com/'); 108 | $urlComponents = $parser->getParts(); 109 | ``` 110 | 111 | After: 112 | 113 | ```php 114 | $parser = new Parser(); 115 | $urlComponents = Parser::parse('http://example.com/); 116 | ``` 117 | 118 | ## Remove Query Model 119 | 120 | The `Query` class was untenable. 121 | 122 | With a query being permitted to contain repeat occurrences of the same key, it is not feasible to form a model 123 | of key:value pairs in a manner that allows the original un-parsed string representation to be re-formed in an 124 | identical manner. 125 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webignition/url", 3 | "description": "A PSR-7 UriInterface implementation. A normalizer for applying sixteen lossy and lossless normalizations.", 4 | "keywords": ["url", "uri", "psr-7", "normalise", "normalize", "normaliser", "normalizer"], 5 | "homepage": "https://github.com/webignition/url", 6 | "type": "library", 7 | "license": "MIT", 8 | "abandoned": "webignition/uri", 9 | "authors": [ 10 | { 11 | "name": "Jon Cram", 12 | "email": "jon@webignition.net" 13 | } 14 | ], 15 | "autoload": { 16 | "psr-4": { 17 | "webignition\\Url\\": "src" 18 | } 19 | }, 20 | "autoload-dev": { 21 | "psr-4": { 22 | "webignition\\Url\\Tests\\": "tests" 23 | } 24 | }, 25 | "scripts": { 26 | "test": "./vendor/bin/phpunit --colors=always", 27 | "cs": "./vendor/bin/phpcs src tests --colors --standard=PSR2", 28 | "ci": [ 29 | "@composer cs", 30 | "@composer test" 31 | ] 32 | }, 33 | "require": { 34 | "php": ">=7.2", 35 | "psr/http-message": "^1", 36 | "mso/idna-convert": "^1", 37 | "xrstf/ip-utils": "v1.0.0" 38 | }, 39 | "require-dev": { 40 | "phpunit/phpunit": "^7", 41 | "squizlabs/php_codesniffer": "3.*", 42 | "mockery/mockery": "^1", 43 | "php-mock/php-mock-mockery": "^1" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = . 8 | BUILDDIR = _build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/_static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webignition/url/d89b8b8f9ac545699f91d8389718c864f4cbbb10/docs/_static/.gitkeep -------------------------------------------------------------------------------- /docs/applying-multiple-normalizations.rst: -------------------------------------------------------------------------------- 1 | ================================ 2 | Applying Multiple Normalizations 3 | ================================ 4 | 5 | You can apply any number of the sixteen normalizations when normalizing a ``UriInterface`` instance. 6 | 7 | -------------- 8 | Multiple Flags 9 | -------------- 10 | 11 | Combine flags using the bitwise ``|`` operator. 12 | 13 | .. code-block:: php 14 | 15 | [ 46 | '/^utm_\w+/i', 47 | ], 48 | ]); 49 | 50 | (string) $normalizedUrl; 51 | // "http://example.com?page=1&category=2" 52 | 53 | ---------------------------------------------- 54 | Apply All Semantically-Lossless Normalizations 55 | ---------------------------------------------- 56 | 57 | A set of normalizations that do not change the semantics of a URL are defined as 58 | ``Normalizer::PRESERVING_NORMALIZATIONS``. 59 | 60 | Read more about :doc:`semantically-lossless normalizations ` to see what 61 | flags this applies. 62 | 63 | .. code-block:: php 64 | 65 | ` 6 | - :ref:`decode unreserved characters ` 7 | - :ref:`convert empty http path ` 8 | - :ref:`remove default file host ` 9 | - :ref:`remove port host ` 10 | - :ref:`remove path dot segments ` 11 | - :ref:`convert host unicode to punycode ` 12 | - :ref:`reduce duplicate path slashes ` 13 | - :ref:`sort query parameters ` 14 | - :ref:`add path trailing slash `- 15 | - :ref:`remove user info ` 16 | - :ref:`remove fragment ` 17 | - :ref:`remove www sub-domain ` 18 | 19 | .. rst-class:: precede-list 20 | 21 | Options: 22 | 23 | - :ref:`specify a default scheme ` 24 | - :ref:`remove filenames from path by pattern ` 25 | - :ref:`remove query parameters by pattern ` 26 | -------------------------------------------------------------------------------- /docs/includes/parser/example.rst: -------------------------------------------------------------------------------- 1 | .. code-block:: php 2 | 3 | getScheme(); 10 | // "http" 11 | 12 | $url->getQuery(); 13 | // "query" 14 | 15 | $modifiedUrl = $url 16 | ->withScheme('https') 17 | ->withPath('/modified-path') 18 | ->withQuery('foo=bar') 19 | ->withFragment(''); 20 | (string) $modifiedUrl; 21 | // https://example.com/modified-path?foo=bar 22 | -------------------------------------------------------------------------------- /docs/includes/url/introduction.rst: -------------------------------------------------------------------------------- 1 | A ``Url`` models a `URL `_, providing component access 2 | and modification through the `PSR7 UriInterface `_. 3 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. title:: URL Documentation 2 | 3 | ================= 4 | URL Documentation 5 | ================= 6 | 7 | The `webginition/url `_ package models, normalizes, parses and 8 | compares URLs. 9 | 10 | The :doc:`overview ` provides a high-level introduction to the package classes. 11 | 12 | You might want to skip straight to the :doc:`Url usage guide ` or the :doc:`Normalizer usage guide `. 13 | 14 | .. toctree:: 15 | :hidden: 16 | :caption: First Steps 17 | 18 | overview 19 | requirements-and-installation 20 | 21 | .. toctree:: 22 | :caption: Usage 23 | :maxdepth: 3 24 | 25 | url 26 | normalizer 27 | 28 | .. toctree:: 29 | :caption: Normalization Details 30 | :maxdepth: 3 31 | 32 | applying-multiple-normalizations 33 | semantically-lossless-normalizations 34 | potentially-lossy-normalizations 35 | lossy-normalizations 36 | -------------------------------------------------------------------------------- /docs/lossy-normalizations.rst: -------------------------------------------------------------------------------- 1 | ==================== 2 | Lossy Normalizations 3 | ==================== 4 | 5 | .. rst-class:: precede-list 6 | 7 | Lossy normalizations change, to a greater or lesser extent, the semantics of a URL. 8 | 9 | - :ref:`add path trailing slash `- 10 | - :ref:`remove user info ` 11 | - :ref:`remove fragment ` 12 | - :ref:`remove www sub-domain ` 13 | -------------------------------------------------------------------------------- /docs/normalizer.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Normalizer 3 | ========== 4 | 5 | .. include:: includes/normalizer/introduction.rst 6 | 7 | Normalizations are specified through either `flags` or `options`. Flags are for normalizations that can be turned or 8 | turned off (you either want it or you don't). Options are for normalizations that act on one or more variables that 9 | you get to choose. 10 | 11 | .. include:: includes/normalizer/normalizations-list.rst 12 | 13 | .. _normalizations-capitalize-percent-encoding: 14 | 15 | --------------------------- 16 | Capitalize Percent Encoding 17 | --------------------------- 18 | 19 | Convert percent-encoded triplets (such as ``%3A``) to uppercase. Letters within a percent-encoded triplet are 20 | case-insensitive. 21 | 22 | .. code-block:: php 23 | 24 | `_ equivalent. 156 | 157 | .. code-block:: php 158 | 159 | 'http', 316 | ]); 317 | 318 | (string) $normalizedUrl; 319 | // "http://example.com" 320 | 321 | .. _normalizations-remove-filenames-from-path-by-pattern: 322 | 323 | ------------------------------------- 324 | Remove Filenames From Path By Pattern 325 | ------------------------------------- 326 | 327 | Remove the filename from the path component. Removal is defined through one or more patterns. 328 | 329 | Useful for stripping common default filenames such as ``index.html``, ``index.js`` or ``default.asp``. 330 | 331 | .. code-block:: php 332 | 333 | Normalizer::REMOVE_INDEX_FILE_PATTERN, 341 | ]); 342 | 343 | (string) $normalizedUrl; 344 | // "http://example.com/" 345 | 346 | .. _normalizations-remove-query-parameters-by-pattern: 347 | 348 | ---------------------------------- 349 | Remove Query Parameters By Pattern 350 | ---------------------------------- 351 | 352 | Remove query parameters where the parameter key matches one of a set of patterns. 353 | 354 | Useful for stripping query parameters considered by you to be irrelevant to the canonical form of a URL. 355 | 356 | .. code-block:: php 357 | 358 | [ 366 | '/^utm_\w+/i', 367 | ], 368 | ]); 369 | 370 | (string) $normalizedUrl; 371 | // "http://example.com?page=1&category=2" 372 | -------------------------------------------------------------------------------- /docs/overview.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Overview 3 | ======== 4 | 5 | --- 6 | Url 7 | --- 8 | 9 | .. include:: includes/url/introduction.rst 10 | .. include:: includes/url/example.rst 11 | 12 | Read the :doc:`Url usage ` guide for more detail. 13 | 14 | ---------- 15 | Normalizer 16 | ---------- 17 | 18 | .. rst-class:: precede-list 19 | .. include:: includes/normalizer/introduction.rst 20 | .. include:: includes/normalizer/example.rst 21 | .. include:: includes/normalizer/normalizations-list.rst 22 | 23 | Read the :doc:`Normalizer usage ` guide for more detail. 24 | 25 | ------ 26 | Parser 27 | ------ 28 | 29 | .. include:: includes/parser/introduction.rst 30 | .. include:: includes/parser/example.rst 31 | -------------------------------------------------------------------------------- /docs/potentially-lossy-normalizations.rst: -------------------------------------------------------------------------------- 1 | ================================ 2 | Potentially-Lossy Normalizations 3 | ================================ 4 | 5 | .. rst-class:: precede-list 6 | 7 | The reduce path slashes and sort query parameters normalizations are potentially lossy. Semantic equivalence may 8 | be commonly achieved but is not guaranteed. 9 | 10 | - :ref:`reduce duplicate path slashes ` 11 | - :ref:`sort query parameters ` 12 | 13 | -------------------------------------------------------------------------------- /docs/requirements-and-installation.rst: -------------------------------------------------------------------------------- 1 | ============================= 2 | Requirements and Installation 3 | ============================= 4 | 5 | ------------ 6 | Requirements 7 | ------------ 8 | 9 | - PHP 7.2.0 or greater 10 | - `composer `_ 11 | 12 | .. _getting-started-getting-the-code: 13 | 14 | ------------ 15 | Installation 16 | ------------ 17 | 18 | .. code-block:: bash 19 | 20 | composer require webignition/url ^3 21 | -------------------------------------------------------------------------------- /docs/semantically-lossless-normalizations.rst: -------------------------------------------------------------------------------- 1 | ==================================== 2 | Semantically-Lossless Normalizations 3 | ==================================== 4 | 5 | .. rst-class:: precede-list 6 | 7 | There is a set of normalizations that do not change the semantics of a URL. These are defined as 8 | ``Normalizer::PRESERVING_NORMALIZATIONS``. The normalizer applies this set of normalizations if no specific 9 | normalizations are requested. 10 | 11 | - :ref:`capitalize percent encoding ` 12 | - :ref:`decode unreserved characters ` 13 | - :ref:`convert empty http path ` 14 | - :ref:`remove default file host ` 15 | - :ref:`remove port host ` 16 | - :ref:`remove path dot segments ` 17 | - :ref:`convert host unicode to punycode ` 18 | 19 | .. code-block:: php 20 | 21 | getScheme(); 42 | // "https" 43 | 44 | $url->getUserInfo(); 45 | // "user:password" 46 | 47 | $url->getHost(); 48 | // "example.com" 49 | 50 | $url->getPort(); 51 | // 8080 52 | 53 | $url->getAuthority(); 54 | // "user:password@example.com:8080" 55 | 56 | $url->getPath(); 57 | // "/path" 58 | 59 | $url->getQuery(); 60 | // "query" 61 | 62 | $url->getFragment(); 63 | // "fragment" 64 | 65 | ---------------------- 66 | Component Modification 67 | ---------------------- 68 | 69 | The ``Url::with*()`` are used to set components. A ``Url`` is immutable. The return value is a new ``Url`` instance. 70 | 71 | .. code-block:: php 72 | 73 | withScheme('http'); 82 | (string) $modifiedUrl; 83 | // "http://user:password@example.com:8080/path?query#fragment" 84 | 85 | $url = $url->withUserInfo('new-user', 'new-password'); 86 | (string) $modifiedUrl; 87 | // "http://new-user:new-password@example.com:8080/path?query#fragment" 88 | 89 | $url = $url->withUserInfo(''); 90 | (string) $modifiedUrl; 91 | // "http://example.com:8080/path?query#fragment" 92 | 93 | $url = $url->withHost('new.example.com'); 94 | (string) $modifiedUrl; 95 | // "http://new.example.com:8080/path?query#fragment" 96 | 97 | $url = $url->withPort(null); 98 | (string) $modifiedUrl; 99 | // "http://new.example.com/path?query#fragment" 100 | 101 | $url = $url->withPath(''); 102 | (string) $modifiedUrl; 103 | // "http://new.example.com?query#fragment" 104 | 105 | $url = $url->withQuery(''); 106 | (string) $modifiedUrl; 107 | // "http://new.example.com#fragment" 108 | 109 | $url = $url->withFragment(''); 110 | (string) $modifiedUrl; 111 | // "http://new.example.com" 112 | 113 | -------------------------- 114 | Non-Optional Normalization 115 | -------------------------- 116 | 117 | .. code-block:: php 118 | 119 | getScheme(); 126 | // "https" 127 | 128 | $url->getHost(); 129 | // "example.com" 130 | 131 | $url->getPort(); 132 | // null 133 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 14 | 15 | 16 | 17 | tests/ 18 | 19 | 20 | 21 | 22 | ./src 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/DefaultPortIdentifier.php: -------------------------------------------------------------------------------- 1 | 80, 9 | 'https' => 443, 10 | 'ftp' => 21, 11 | 'gopher' => 70, 12 | 'nntp' => 119, 13 | 'news' => 119, 14 | 'telnet' => 23, 15 | 'tn3270' => 23, 16 | 'imap' => 143, 17 | 'pop' => 110, 18 | 'ldap' => 389, 19 | ]; 20 | 21 | public static function isDefaultPort(?string $scheme, ?int $port): bool 22 | { 23 | $knownPort = self::$schemeToPortMap[$scheme] ?? null; 24 | 25 | return null === $port || $knownPort === $port; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Filter.php: -------------------------------------------------------------------------------- 1 | $port || self::MAX_PORT < $port) { 64 | throw new \InvalidArgumentException( 65 | sprintf('Invalid port: %d. Must be between %d and %d', $port, self::MIN_PORT, self::MAX_PORT) 66 | ); 67 | } 68 | 69 | if (DefaultPortIdentifier::isDefaultPort($scheme, $port)) { 70 | $port = null; 71 | } 72 | 73 | return $port; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Host.php: -------------------------------------------------------------------------------- 1 | host = $host; 61 | $this->parts = explode(self::HOST_PART_SEPARATOR, $host); 62 | } 63 | 64 | public function __toString(): string 65 | { 66 | return $this->host; 67 | } 68 | 69 | public function getParts(): array 70 | { 71 | return $this->parts; 72 | } 73 | 74 | public function equals(Host $comparator): bool 75 | { 76 | return (string) $this === (string) $comparator; 77 | } 78 | 79 | public function isEquivalentTo(Host $comparator, array $excludedParts = []): bool 80 | { 81 | $thisHost = new Host(PunycodeEncoder::encode((string) $this)); 82 | $comparatorHost = new Host(PunycodeEncoder::encode((string) $comparator)); 83 | 84 | if (empty($excludedParts)) { 85 | return $thisHost->equals($comparatorHost); 86 | } 87 | 88 | $thisParts = $this->excludeParts($thisHost->getParts(), $excludedParts); 89 | $comparatorParts = $this->excludeParts($comparatorHost->getParts(), $excludedParts); 90 | 91 | return $thisParts === $comparatorParts; 92 | } 93 | 94 | private function excludeParts(array $parts, array $exclusions): array 95 | { 96 | $filteredParts = []; 97 | 98 | foreach ($parts as $index => $part) { 99 | if (!isset($exclusions[$index]) || $exclusions[$index] !== $part) { 100 | $filteredParts[] = $part; 101 | } 102 | } 103 | 104 | return $filteredParts; 105 | } 106 | 107 | /** 108 | * @return bool 109 | * 110 | * @throws InvalidExpressionException 111 | */ 112 | public function isPubliclyRoutable(): bool 113 | { 114 | try { 115 | $ip = IpUtilsFactory::getAddress($this->host); 116 | 117 | if ($ip->isPrivate()) { 118 | return false; 119 | } 120 | 121 | if ($ip->isLoopback()) { 122 | return false; 123 | } 124 | 125 | if ($ip instanceof IPv4 && $this->isIpv4InUnroutableRange($ip)) { 126 | return false; 127 | } 128 | 129 | return true; 130 | } catch (\UnexpectedValueException $unexpectedValueException) { 131 | return true; 132 | } 133 | } 134 | 135 | /** 136 | * @param IPv4 $ip 137 | * 138 | * @return bool 139 | * 140 | * @throws InvalidExpressionException 141 | */ 142 | private function isIpv4InUnroutableRange(IPv4 $ip): bool 143 | { 144 | foreach ($this->unrouteableRanges as $ipRange) { 145 | if ($ip->matches(new Subnet($ipRange))) { 146 | return true; 147 | } 148 | } 149 | 150 | return false; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/Inspector.php: -------------------------------------------------------------------------------- 1 | getHost(); 29 | if ('' === $host) { 30 | return true; 31 | } 32 | 33 | $hostObject = new Host($host); 34 | 35 | if (!$hostObject->isPubliclyRoutable()) { 36 | return true; 37 | } 38 | 39 | $hostContainsDots = substr_count($host, '.'); 40 | if (!$hostContainsDots) { 41 | return true; 42 | } 43 | 44 | if ('.' === $host[0] || '.' === $host[-1]) { 45 | return true; 46 | } 47 | 48 | return false; 49 | } 50 | 51 | public static function isProtocolRelative(UriInterface $uri): bool 52 | { 53 | $scheme = $uri->getScheme(); 54 | 55 | if ('' !== $scheme) { 56 | return false; 57 | } 58 | 59 | return '' !== $uri->getHost(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Normalizer.php: -------------------------------------------------------------------------------- 1 | getUserInfo()) { 60 | $uri = $uri->withUserInfo(''); 61 | } 62 | 63 | if ($flags & self::REMOVE_FRAGMENT && '' !== $uri->getFragment()) { 64 | $uri = $uri->withFragment(''); 65 | } 66 | 67 | if ('' !== $uri->getHost()) { 68 | $host = $uri->getHost(); 69 | 70 | if ($flags & self::CONVERT_HOST_UNICODE_TO_PUNYCODE) { 71 | $host = PunycodeEncoder::encode($host); 72 | } 73 | 74 | if ($flags & self::REMOVE_WWW) { 75 | if (preg_match(self::HOST_STARTS_WITH_WWW_PATTERN, $host) > 0) { 76 | $host = preg_replace(self::HOST_STARTS_WITH_WWW_PATTERN, '', $host); 77 | } 78 | } 79 | 80 | $uri = $uri->withHost($host); 81 | } 82 | 83 | if ($flags & self::REMOVE_PATH_DOT_SEGMENTS) { 84 | $uri = $uri->withPath(self::removePathDotSegments($uri->getPath())); 85 | } 86 | 87 | if ($flags & self::REDUCE_DUPLICATE_PATH_SLASHES) { 88 | $uri->withPath(preg_replace('#//++#', '/', $uri->getPath())); 89 | } 90 | 91 | if ($flags & self::ADD_PATH_TRAILING_SLASH) { 92 | $uri = $uri->withPath(self::addPathTrailingSlash($uri->getPath())); 93 | } 94 | 95 | if ($flags & self::SORT_QUERY_PARAMETERS && '' !== $uri->getQuery()) { 96 | $query = self::mutateQuery($uri->getQuery(), function (array &$queryKeyValues) { 97 | sort($queryKeyValues); 98 | }); 99 | 100 | $uri = $uri->withQuery($query); 101 | } 102 | 103 | if ($flags & self::DECODE_UNRESERVED_CHARACTERS) { 104 | $uri = self::applyPregReplaceCallbackToPathAndQuery( 105 | $uri, 106 | '/%(?:2D|2E|5F|7E|3[0-9]|[46][1-9A-F]|[57][0-9A])/i', 107 | function (array $match) { 108 | return rawurldecode($match[0]); 109 | } 110 | ); 111 | } 112 | 113 | if ($flags & self::REMOVE_DEFAULT_PORT) { 114 | if (DefaultPortIdentifier::isDefaultPort($uri->getScheme(), $uri->getPort())) { 115 | $uri = $uri->withPort(null); 116 | } 117 | } 118 | 119 | if ($flags & self::CAPITALIZE_PERCENT_ENCODING) { 120 | $uri = self::applyPregReplaceCallbackToPathAndQuery( 121 | $uri, 122 | '/(?:%[A-Fa-f0-9]{2})++/', 123 | function (array $match) { 124 | return strtoupper($match[0]); 125 | } 126 | ); 127 | } 128 | 129 | if ($flags & self::CONVERT_EMPTY_HTTP_PATH && $uri->getPath() === '' && 130 | (self::SCHEME_HTTP === $uri->getScheme() || self::SCHEME_HTTPS === $uri->getScheme()) 131 | ) { 132 | $uri = $uri->withPath('/'); 133 | } 134 | 135 | if ($flags & self::REMOVE_DEFAULT_FILE_HOST && 136 | self::SCHEME_FILE === $uri->getScheme() && 'localhost' === $uri->getHost()) { 137 | $uri = $uri->withHost(''); 138 | } 139 | } 140 | 141 | if (isset($options[self::OPTION_REMOVE_PATH_FILES_PATTERNS])) { 142 | $uri = $uri->withPath(self::removePathFiles( 143 | $uri->getPath(), 144 | $options[self::OPTION_REMOVE_PATH_FILES_PATTERNS] 145 | )); 146 | } 147 | 148 | if (isset($options[self::OPTION_REMOVE_QUERY_PARAMETERS_PATTERNS]) && 149 | is_array($options[self::OPTION_REMOVE_QUERY_PARAMETERS_PATTERNS])) { 150 | $patterns = $options[self::OPTION_REMOVE_QUERY_PARAMETERS_PATTERNS]; 151 | 152 | $query = self::mutateQuery($uri->getQuery(), function (array &$queryKeyValues) use ($patterns) { 153 | $queryKeyValues = call_user_func( 154 | [Normalizer::class, 'removeQueryParameters'], 155 | $queryKeyValues, 156 | $patterns 157 | ); 158 | }); 159 | 160 | $uri = $uri->withQuery($query); 161 | } 162 | 163 | return $uri; 164 | } 165 | 166 | private static function removePathFiles(string $path, array $patterns): string 167 | { 168 | if ('' === $path) { 169 | return $path; 170 | } 171 | 172 | $pathObject = new Path($path); 173 | if (!$pathObject->hasFilename()) { 174 | return $path; 175 | } 176 | 177 | $filename = $pathObject->getFilename(); 178 | 179 | $hasFilenameToRemove = false; 180 | foreach ($patterns as $pattern) { 181 | if (preg_match($pattern, $filename) > 0) { 182 | $hasFilenameToRemove = true; 183 | } 184 | } 185 | 186 | if ($hasFilenameToRemove) { 187 | $path = (string) $pathObject; 188 | $pathParts = explode(self::PATH_SEPARATOR, $path); 189 | 190 | array_pop($pathParts); 191 | 192 | $path = implode(self::PATH_SEPARATOR, $pathParts); 193 | } 194 | 195 | return $path; 196 | } 197 | 198 | private static function removePathDotSegments(string $path): string 199 | { 200 | if ('' === $path || '/' === $path) { 201 | return $path; 202 | } 203 | 204 | if (in_array($path, ['/..', '/.'])) { 205 | return '/'; 206 | } 207 | 208 | $lastCharacter = $path[-1]; 209 | $pathParts = explode('/', $path); 210 | $normalisedPathParts = []; 211 | 212 | foreach ($pathParts as $pathPart) { 213 | if ('.' === $pathPart) { 214 | continue; 215 | } 216 | 217 | if ('..' === $pathPart) { 218 | array_pop($normalisedPathParts); 219 | } else { 220 | $normalisedPathParts[] = $pathPart; 221 | } 222 | } 223 | 224 | $path = implode('/', $normalisedPathParts); 225 | 226 | if (empty($path) && '/' === $lastCharacter) { 227 | $path = '/'; 228 | } 229 | 230 | return $path; 231 | } 232 | 233 | private static function addPathTrailingSlash(string $path): string 234 | { 235 | if ('' === $path) { 236 | return '/'; 237 | } 238 | 239 | $pathObject = new Path($path); 240 | 241 | if (!$pathObject->hasFilename() && !$pathObject->hasTrailingSlash()) { 242 | $path = $path . '/'; 243 | } 244 | 245 | return $path; 246 | } 247 | 248 | private static function applyPregReplaceCallbackToPathAndQuery( 249 | UriInterface $uri, 250 | string $regex, 251 | callable $callback 252 | ): UriInterface { 253 | return 254 | $uri->withPath( 255 | preg_replace_callback($regex, $callback, $uri->getPath()) 256 | )->withQuery( 257 | preg_replace_callback($regex, $callback, $uri->getQuery()) 258 | ); 259 | } 260 | 261 | private static function removeQueryParameters(array $queryKeyValues, array $patterns): array 262 | { 263 | foreach ($patterns as $pattern) { 264 | $queryKeyValues = array_filter( 265 | $queryKeyValues, 266 | function (string $keyValue) use ($pattern) { 267 | return call_user_func([Normalizer::class, 'queryKeyValueKeyMatchesPattern'], $keyValue, $pattern); 268 | } 269 | ); 270 | } 271 | 272 | return $queryKeyValues; 273 | } 274 | 275 | private static function queryKeyValueKeyMatchesPattern(string $keyValue, string $pattern): bool 276 | { 277 | $firstEqualsPosition = strpos($keyValue, '='); 278 | 279 | $key = $firstEqualsPosition 280 | ? substr($keyValue, 0, $firstEqualsPosition) 281 | : $keyValue; 282 | 283 | return preg_match($pattern, $key) === 0; 284 | } 285 | 286 | private static function mutateQuery(string $query, callable $mutator): string 287 | { 288 | $queryKeyValues = explode(self::QUERY_KEY_VALUE_DELIMITER, $query); 289 | $mutator($queryKeyValues); 290 | 291 | return implode(self::QUERY_KEY_VALUE_DELIMITER, $queryKeyValues); 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /src/Parser.php: -------------------------------------------------------------------------------- 1 | preg_replace('/:\/\/$/', '', $url), 105 | ]; 106 | } 107 | 108 | return []; 109 | } 110 | 111 | private static function parseUrlWithInvalidPort(string $url): array 112 | { 113 | $components = self::parseUrlWithInvalidPortWithPath($url); 114 | if (!empty($components)) { 115 | return $components; 116 | } 117 | 118 | $components = self::parseUrlWithInvalidPortWithoutPathWithQuery($url); 119 | if (!empty($components)) { 120 | return $components; 121 | } 122 | 123 | $components = self::parseUrlWithInvalidPOrtWithoutPathWithoutQueryWithFragment($url); 124 | if (!empty($components)) { 125 | return $components; 126 | } 127 | 128 | $components = self::parseUrlEndingWithPortPattern($url); 129 | if (!empty($components)) { 130 | return $components; 131 | } 132 | 133 | return []; 134 | } 135 | 136 | private static function parseUrlWithInvalidPortWithPath(string $url): array 137 | { 138 | $doubleSlashPosition = strpos($url, '//'); 139 | 140 | $firstSlashSearchOffset = false === $doubleSlashPosition 141 | ? 0 142 | : $doubleSlashPosition + 2; 143 | 144 | $firstSlashPosition = strpos($url, '/', $firstSlashSearchOffset); 145 | 146 | if (false === $firstSlashPosition) { 147 | return []; 148 | } 149 | 150 | return self::parseUrlEndingWithPortPatternAndSuffix($url, $firstSlashPosition); 151 | } 152 | 153 | private static function parseUrlWithInvalidPortWithoutPathWithQuery(string $url): array 154 | { 155 | $queryDelimiterPosition = strpos($url, self::QUERY_DELIMITER); 156 | 157 | if (false === $queryDelimiterPosition) { 158 | return []; 159 | } 160 | 161 | $fragmentDelimiterPosition = strpos($url, self::FRAGMENT_DELIMITER); 162 | 163 | if (false !== $fragmentDelimiterPosition && $fragmentDelimiterPosition < $queryDelimiterPosition) { 164 | return []; 165 | } 166 | 167 | return self::parseUrlEndingWithPortPatternAndSuffix($url, $queryDelimiterPosition); 168 | } 169 | 170 | private static function parseUrlWithInvalidPOrtWithoutPathWithoutQueryWithFragment(string $url): array 171 | { 172 | $fragmentDelimiterPosition = strpos($url, self::FRAGMENT_DELIMITER); 173 | 174 | if (false === $fragmentDelimiterPosition) { 175 | return []; 176 | } 177 | 178 | return self::parseUrlEndingWithPortPatternAndSuffix($url, $fragmentDelimiterPosition); 179 | } 180 | 181 | private static function parseUrlEndingWithPortPatternAndSuffix(string $url, int $suffixPosition) 182 | { 183 | $urlEndingWithPortPattern = substr($url, 0, $suffixPosition); 184 | $suffix = substr($url, $suffixPosition); 185 | 186 | return self::parseUrlEndingWithPortPattern($urlEndingWithPortPattern, $suffix); 187 | } 188 | 189 | private static function parseUrlEndingWithPortPattern(string $urlEndingWithPortPattern, string $postPortSuffix = '') 190 | { 191 | $endsWithPortPattern = '/\:[0-9]+$/'; 192 | $endsWithPortMatches = []; 193 | 194 | if (preg_match($endsWithPortPattern, $urlEndingWithPortPattern, $endsWithPortMatches) > 0) { 195 | $modifiedUrl = preg_replace($endsWithPortPattern, '', $urlEndingWithPortPattern); 196 | $port = str_replace(':', '', $endsWithPortMatches[0]); 197 | 198 | $modifiedUrl .= $postPortSuffix; 199 | 200 | $components = parse_url($modifiedUrl); 201 | $components[self::COMPONENT_PORT] = $port; 202 | 203 | return $components; 204 | } 205 | 206 | return []; 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/Path.php: -------------------------------------------------------------------------------- 1 | path = Filter::filterPath($path); 17 | } 18 | 19 | public function isRelative(): bool 20 | { 21 | return '' === $this->path 22 | ? true 23 | : self::PATH_PART_SEPARATOR !== $this->path[0]; 24 | } 25 | 26 | public function isAbsolute(): bool 27 | { 28 | return '' === $this->path 29 | ? false 30 | : self::PATH_PART_SEPARATOR === $this->path[0]; 31 | } 32 | 33 | public function __toString(): string 34 | { 35 | return $this->path; 36 | } 37 | 38 | public function hasFilename(): bool 39 | { 40 | if ('' === $this->path || self::PATH_PART_SEPARATOR === $this->path[-1]) { 41 | return false; 42 | } 43 | 44 | return substr_count(basename($this->path), '.') > 0; 45 | } 46 | 47 | public function getFilename(): string 48 | { 49 | return $this->hasFilename() ? basename($this->path) : ''; 50 | } 51 | 52 | public function getDirectory(): string 53 | { 54 | return $this->hasFilename() ? dirname($this->path) : $this->path; 55 | } 56 | 57 | public function hasTrailingSlash(): bool 58 | { 59 | return '' === $this->path 60 | ? false 61 | : self::PATH_PART_SEPARATOR === $this->path[-1]; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/PunycodeEncoder.php: -------------------------------------------------------------------------------- 1 | encode($value); 13 | } catch (\InvalidArgumentException $invalidArgumentException) { 14 | } 15 | 16 | return $value; 17 | } 18 | 19 | public static function decode(string $value): string 20 | { 21 | return (new IdnaConvert())->decode($value); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/ScopeComparer.php: -------------------------------------------------------------------------------- 1 | equivalentSchemes[] = $schemes; 25 | } 26 | 27 | /** 28 | * @param string[] $hosts 29 | */ 30 | public function addEquivalentHosts(array $hosts) 31 | { 32 | $this->equivalentHosts[] = $hosts; 33 | } 34 | 35 | /** 36 | * Is the given comparator url in the scope of the source url? 37 | * 38 | * Comparator is in the same scope as the source if: 39 | * - scheme is the same or equivalent (e.g. http and https are equivalent) 40 | * - hostname is the same or equivalent (equivalency looks at subdomain equivalence 41 | * e.g. example.com and www.example.com) 42 | * - path is the same or greater (e.g. sourcepath = /one/two, comparatorpath = /one/two or /one/two/* 43 | * 44 | * Comparison ignores: 45 | * - port 46 | * - user 47 | * - pass 48 | * - query 49 | * - fragment 50 | * 51 | * @param UriInterface $source 52 | * @param UriInterface $comparator 53 | * 54 | * @return bool 55 | */ 56 | public function isInScope(UriInterface $source, UriInterface $comparator): bool 57 | { 58 | $source = $this->removeIgnoredComponents($source); 59 | $comparator = $this->removeIgnoredComponents($comparator); 60 | 61 | $sourceString = (string) $source; 62 | $comparatorString = (string) $comparator; 63 | 64 | if ($sourceString === $comparatorString) { 65 | return true; 66 | } 67 | 68 | if ($this->isSourceUrlSubstringOfComparatorUrl($sourceString, $comparatorString)) { 69 | return true; 70 | } 71 | 72 | if (!$this->areSchemesEquivalent($source->getScheme(), $comparator->getScheme())) { 73 | return false; 74 | } 75 | 76 | if (!$this->areHostsEquivalent($source->getHost(), $comparator->getHost())) { 77 | return false; 78 | } 79 | 80 | return $this->isSourcePathSubstringOfComparatorPath($source->getPath(), $comparator->getPath()); 81 | } 82 | 83 | private function isSourceUrlSubstringOfComparatorUrl(string $source, string $comparator): bool 84 | { 85 | return strpos($comparator, $source) === 0; 86 | } 87 | 88 | private function areSchemesEquivalent(string $source, string $comparator): bool 89 | { 90 | return $this->areUrlPartsEquivalent($source, $comparator, $this->equivalentSchemes); 91 | } 92 | 93 | private function areHostsEquivalent(string $source, string $comparator): bool 94 | { 95 | return $this->areUrlPartsEquivalent($source, $comparator, $this->equivalentHosts); 96 | } 97 | 98 | private function areUrlPartsEquivalent(string $sourceValue, string $comparatorValue, array $equivalenceSets): bool 99 | { 100 | if ($sourceValue === $comparatorValue) { 101 | return true; 102 | } 103 | 104 | foreach ($equivalenceSets as $equivalenceSet) { 105 | if (in_array($sourceValue, $equivalenceSet) && in_array($comparatorValue, $equivalenceSet)) { 106 | return true; 107 | } 108 | } 109 | 110 | return false; 111 | } 112 | 113 | private function isSourcePathSubstringOfComparatorPath(string $source, string $comparator): bool 114 | { 115 | if ('' === $source) { 116 | return true; 117 | } 118 | 119 | return 0 === strpos($comparator, $source); 120 | } 121 | 122 | private function removeIgnoredComponents(UriInterface $uri): UriInterface 123 | { 124 | $uri = $uri->withPort(null); 125 | $uri = $uri->withUserInfo(''); 126 | $uri = $uri->withQuery(''); 127 | $uri = $uri->withFragment(''); 128 | 129 | return $uri; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/Url.php: -------------------------------------------------------------------------------- 1 | scheme; 65 | } 66 | 67 | public function getAuthority(): string 68 | { 69 | $authority = $this->host; 70 | if ('' !== $this->userInfo) { 71 | $authority = $this->userInfo . '@' . $authority; 72 | } 73 | 74 | if (null !== $this->port) { 75 | $authority .= ':' . $this->port; 76 | } 77 | 78 | return $authority; 79 | } 80 | 81 | public function getUserInfo(): string 82 | { 83 | return $this->userInfo; 84 | } 85 | 86 | public function getHost(): string 87 | { 88 | return $this->host; 89 | } 90 | 91 | public function getPort() 92 | { 93 | return $this->port; 94 | } 95 | 96 | public function getPath(): string 97 | { 98 | return $this->path; 99 | } 100 | 101 | public function getQuery(): string 102 | { 103 | return $this->query; 104 | } 105 | 106 | public function getFragment() 107 | { 108 | return $this->fragment; 109 | } 110 | 111 | public function withScheme($scheme) 112 | { 113 | $scheme = trim(strtolower($scheme)); 114 | 115 | if ($this->scheme === $scheme) { 116 | return $this; 117 | } 118 | 119 | return self::applyComponents( 120 | clone $this, 121 | $scheme, 122 | $this->userInfo, 123 | $this->host, 124 | $this->port, 125 | $this->path, 126 | $this->query, 127 | $this->fragment 128 | ); 129 | } 130 | 131 | public function withUserInfo($user, $password = null) 132 | { 133 | $userInfo = (string) (new UserInfo($user, $password)); 134 | 135 | if ($this->userInfo === $userInfo) { 136 | return $this; 137 | } 138 | 139 | return self::applyComponents( 140 | clone $this, 141 | $this->scheme, 142 | $userInfo, 143 | $this->host, 144 | $this->port, 145 | $this->path, 146 | $this->query, 147 | $this->fragment 148 | ); 149 | } 150 | 151 | public function withHost($host) 152 | { 153 | $host = trim(strtolower($host)); 154 | 155 | if ($this->host === $host) { 156 | return $this; 157 | } 158 | 159 | return self::applyComponents( 160 | clone $this, 161 | $this->scheme, 162 | $this->userInfo, 163 | $host, 164 | $this->port, 165 | $this->path, 166 | $this->query, 167 | $this->fragment 168 | ); 169 | } 170 | 171 | public function withPort($port) 172 | { 173 | if (null !== $port) { 174 | $port = (int) $port; 175 | } 176 | 177 | if ($this->port === $port) { 178 | return $this; 179 | } 180 | 181 | return self::applyComponents( 182 | clone $this, 183 | $this->scheme, 184 | $this->userInfo, 185 | $this->host, 186 | $port, 187 | $this->path, 188 | $this->query, 189 | $this->fragment 190 | ); 191 | } 192 | 193 | public function withPath($path) 194 | { 195 | $path = Filter::filterPath($path); 196 | 197 | if ($this->path === $path) { 198 | return $this; 199 | } 200 | 201 | return self::applyComponents( 202 | clone $this, 203 | $this->scheme, 204 | $this->userInfo, 205 | $this->host, 206 | $this->port, 207 | $path, 208 | $this->query, 209 | $this->fragment 210 | ); 211 | } 212 | 213 | public function withQuery($query) 214 | { 215 | $query = Filter::filterQueryOrFragment($query); 216 | 217 | if ($this->query === $query) { 218 | return $this; 219 | } 220 | 221 | return self::applyComponents( 222 | clone $this, 223 | $this->scheme, 224 | $this->userInfo, 225 | $this->host, 226 | $this->port, 227 | $this->path, 228 | $query, 229 | $this->fragment 230 | ); 231 | } 232 | 233 | public function withFragment($fragment) 234 | { 235 | $fragment = Filter::filterQueryOrFragment($fragment); 236 | 237 | if ($this->fragment === $fragment) { 238 | return $this; 239 | } 240 | 241 | return self::applyComponents( 242 | clone $this, 243 | $this->scheme, 244 | $this->userInfo, 245 | $this->host, 246 | $this->port, 247 | $this->path, 248 | $this->query, 249 | $fragment 250 | ); 251 | } 252 | 253 | public function __toString() 254 | { 255 | $uri = ''; 256 | 257 | if ('' !== $this->scheme) { 258 | $uri .= $this->scheme . ':'; 259 | } 260 | 261 | $authority = $this->getAuthority(); 262 | 263 | if ('' !== $authority|| 'file' === $this->scheme) { 264 | $uri .= '//' . $authority; 265 | } 266 | 267 | $path = $this->path; 268 | 269 | if ($authority && $path && '/' !== $path[0]) { 270 | $path = '/' . $path; 271 | } 272 | 273 | if ('' === $authority && preg_match('/^\/\//', $path)) { 274 | $path = '/' . ltrim($path, '/'); 275 | } 276 | 277 | $uri .= $path; 278 | 279 | if ('' !== $this->query) { 280 | $uri .= '?' . $this->query; 281 | } 282 | 283 | if ('' !== $this->fragment) { 284 | $uri .= '#' . $this->fragment; 285 | } 286 | 287 | return $uri; 288 | } 289 | 290 | private static function applyComponents( 291 | Url $url, 292 | string $scheme, 293 | string $userInfo, 294 | string $host, 295 | ?int $port, 296 | string $path, 297 | string $query, 298 | string $fragment 299 | ): UriInterface { 300 | $url->scheme = strtolower($scheme); 301 | $url->userInfo = $userInfo; 302 | $url->host = strtolower($host); 303 | $url->path = Filter::filterPath($path); 304 | $url->query = Filter::filterQueryOrFragment($query); 305 | $url->fragment = Filter::filterQueryOrFragment($fragment); 306 | $url->port = Filter::filterPort($port, $url->getScheme()); 307 | 308 | return $url; 309 | } 310 | } 311 | -------------------------------------------------------------------------------- /src/UserInfo.php: -------------------------------------------------------------------------------- 1 | user = $user; 15 | $this->password = $password; 16 | } 17 | 18 | public static function fromString(string $userInfo): UserInfo 19 | { 20 | $parts = explode(self::USER_PASS_DELIMITER, $userInfo, 2); 21 | $partCount = count($parts); 22 | 23 | $user = ''; 24 | $password = null; 25 | 26 | if ($partCount) { 27 | $user = $parts[0]; 28 | 29 | if ($partCount > 1) { 30 | $password = $parts[1]; 31 | $password = empty($password) ? null : $password; 32 | } 33 | } 34 | 35 | return new static($user, $password); 36 | } 37 | 38 | public function getUser(): string 39 | { 40 | return $this->user; 41 | } 42 | 43 | public function getPassword(): ?string 44 | { 45 | return $this->password; 46 | } 47 | 48 | public function __toString(): string 49 | { 50 | $userInfo = ''; 51 | 52 | if (!empty($this->user)) { 53 | $userInfo .= $this->user; 54 | } 55 | 56 | if (!empty($this->password)) { 57 | $userInfo .= self::USER_PASS_DELIMITER . $this->password; 58 | } 59 | 60 | return $userInfo; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/DefaultPortIdentifierTest.php: -------------------------------------------------------------------------------- 1 | assertSame($expectedIsDefaultPort, DefaultPortIdentifier::isDefaultPort($scheme, $port)); 19 | } 20 | 21 | public function isDefaultPortDataProvider(): array 22 | { 23 | return [ 24 | 'scheme: null, port: x' => [ 25 | 'scheme' => null, 26 | 'port' => 80, 27 | 'expectedIsDefaultPort' => false, 28 | ], 29 | 'scheme: x, port: null' => [ 30 | 'scheme' => 'http', 31 | 'port' => null, 32 | 'expectedIsDefaultPort' => true, 33 | ], 34 | 'scheme: http, port: 80' => [ 35 | 'scheme' => 'http', 36 | 'port' => 80, 37 | 'expectedIsDefaultPort' => true, 38 | ], 39 | 'scheme: http, port: 999' => [ 40 | 'scheme' => 'http', 41 | 'port' => 999, 42 | 'expectedIsDefaultPort' => false, 43 | ], 44 | 'scheme: https, port: 443' => [ 45 | 'scheme' => 'https', 46 | 'port' => 443, 47 | 'expectedIsDefaultPort' => true, 48 | ], 49 | 'scheme: https, port: 999' => [ 50 | 'scheme' => 'https', 51 | 'port' => 999, 52 | 'expectedIsDefaultPort' => false, 53 | ], 54 | 'scheme: ftp, port: 21' => [ 55 | 'scheme' => 'ftp', 56 | 'port' => 21, 57 | 'expectedIsDefaultPort' => true, 58 | ], 59 | 'scheme: ftp, port: 999' => [ 60 | 'scheme' => 'ftp', 61 | 'port' => 999, 62 | 'expectedIsDefaultPort' => false, 63 | ], 64 | 'scheme: gopher, port: 70' => [ 65 | 'scheme' => 'gopher', 66 | 'port' => 70, 67 | 'expectedIsDefaultPort' => true, 68 | ], 69 | 'scheme: gopher, port: 999' => [ 70 | 'scheme' => 'gopher', 71 | 'port' => 999, 72 | 'expectedIsDefaultPort' => false, 73 | ], 74 | 'scheme: nntp, port: 119' => [ 75 | 'scheme' => 'nntp', 76 | 'port' => 119, 77 | 'expectedIsDefaultPort' => true, 78 | ], 79 | 'scheme: nntp, port: 999' => [ 80 | 'scheme' => 'nntp', 81 | 'port' => 999, 82 | 'expectedIsDefaultPort' => false, 83 | ], 84 | 'scheme: news, port: 119' => [ 85 | 'scheme' => 'news', 86 | 'port' => 119, 87 | 'expectedIsDefaultPort' => true, 88 | ], 89 | 'scheme: news, port: 999' => [ 90 | 'scheme' => 'news', 91 | 'port' => 999, 92 | 'expectedIsDefaultPort' => false, 93 | ], 94 | 'scheme: telnet, port: 23' => [ 95 | 'scheme' => 'telnet', 96 | 'port' => 23, 97 | 'expectedIsDefaultPort' => true, 98 | ], 99 | 'scheme: telnet, port: 999' => [ 100 | 'scheme' => 'telnet', 101 | 'port' => 999, 102 | 'expectedIsDefaultPort' => false, 103 | ], 104 | 'scheme: tn3270, port: 23' => [ 105 | 'scheme' => 'tn3270', 106 | 'port' => 23, 107 | 'expectedIsDefaultPort' => true, 108 | ], 109 | 'scheme: tn3270, port: 999' => [ 110 | 'scheme' => 'tn3270', 111 | 'port' => 999, 112 | 'expectedIsDefaultPort' => false, 113 | ], 114 | 'scheme: imap, port: 143' => [ 115 | 'scheme' => 'imap', 116 | 'port' => 143, 117 | 'expectedIsDefaultPort' => true, 118 | ], 119 | 'scheme: imap, port: 999' => [ 120 | 'scheme' => 'imap', 121 | 'port' => 999, 122 | 'expectedIsDefaultPort' => false, 123 | ], 124 | 'scheme: pop, port: 110' => [ 125 | 'scheme' => 'pop', 126 | 'port' => 110, 127 | 'expectedIsDefaultPort' => true, 128 | ], 129 | 'scheme: pop, port: 999' => [ 130 | 'scheme' => 'pop', 131 | 'port' => 999, 132 | 'expectedIsDefaultPort' => false, 133 | ], 134 | 'scheme: ldap, port: 389' => [ 135 | 'scheme' => 'ldap', 136 | 'port' => 389, 137 | 'expectedIsDefaultPort' => true, 138 | ], 139 | 'scheme: ldap, port: 999' => [ 140 | 'scheme' => 'ldap', 141 | 'port' => 999, 142 | 'expectedIsDefaultPort' => false, 143 | ], 144 | ]; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /tests/FilterTest.php: -------------------------------------------------------------------------------- 1 | assertSame($expectedPath, Filter::filterPath($path)); 20 | } 21 | 22 | public function filterPathDataProvider(): array 23 | { 24 | return [ 25 | 'relative path' => [ 26 | 'path' => 'path', 27 | 'expectedPath' => 'path', 28 | ], 29 | 'absolute path' => [ 30 | 'path' => '/path', 31 | 'expectedPath' => '/path', 32 | ], 33 | 'percent-encode spaces' => [ 34 | 'path' => '/pa th', 35 | 'expectedPath' => '/pa%20th', 36 | ], 37 | 'percent-encode multi-byte characters' => [ 38 | 'path' => '/€', 39 | 'expectedPath' => '/%E2%82%AC', 40 | ], 41 | 'do not double encode' => [ 42 | 'path' => '/pa%20th', 43 | 'expectedPath' => '/pa%20th', 44 | ], 45 | 'percent-encode invalid percent encodings' => [ 46 | 'path' => '/pa%2-th', 47 | 'expectedPath' => '/pa%252-th', 48 | ], 49 | 'do not encode path separators' => [ 50 | 'path' => '/pa/th//two', 51 | 'expectedPath' => '/pa/th//two', 52 | ], 53 | 'do not encode unreserved characters' => [ 54 | 'path' => '/' . self::UNRESERVED_CHARACTERS, 55 | 'expectedPath' => '/' . self::UNRESERVED_CHARACTERS, 56 | ], 57 | 'encoded unreserved characters are not decoded' => [ 58 | 'path' => '/p%61th', 59 | 'expectedPath' => '/p%61th', 60 | ], 61 | 'encode reserved characters' => [ 62 | 'path' => '/?#[]', 63 | 'expectedPath' => '/%3F%23%5B%5D', 64 | ], 65 | ]; 66 | } 67 | 68 | /** 69 | * @dataProvider filterQueryOrFragmentDataProvider 70 | * 71 | * @param string $queryOrFragment 72 | * @param string $expectedQuery 73 | */ 74 | public function testFilterQueryOrFragment(string $queryOrFragment, string $expectedQuery) 75 | { 76 | $this->assertSame($expectedQuery, Filter::filterQueryOrFragment($queryOrFragment)); 77 | } 78 | 79 | public function filterQueryOrFragmentDataProvider(): array 80 | { 81 | return [ 82 | 'percent-encode spaces' => [ 83 | 'queryOrFragment' => 'f o=b r', 84 | 'expectedQuery' => 'f%20o=b%20r', 85 | ], 86 | 'do not encode plus' => [ 87 | 'queryOrFragment' => 'f+o=b+r', 88 | 'expectedQuery' => 'f+o=b+r', 89 | ], 90 | 'percent-encode multi-byte characters' => [ 91 | 'queryOrFragment' => '€=€', 92 | 'expectedQuery' => '%E2%82%AC=%E2%82%AC', 93 | ], 94 | 'do not double encode' => [ 95 | 'queryOrFragment' => 'f%20o=b%20r', 96 | 'expectedQuery' => 'f%20o=b%20r', 97 | ], 98 | 'percent-encode invalid percent encodings' => [ 99 | 'queryOrFragment' => 'f%2o=b%2r', 100 | 'expectedQuery' => 'f%252o=b%252r', 101 | ], 102 | 'do not encode path separators' => [ 103 | 'queryOrFragment' => 'q=va/lue', 104 | 'expectedQuery' => 'q=va/lue', 105 | ], 106 | 'do not encode unreserved characters' => [ 107 | 'queryOrFragment' => self::UNRESERVED_CHARACTERS, 108 | 'expectedQuery' => self::UNRESERVED_CHARACTERS, 109 | ], 110 | 'encoded unreserved characters are not decoded' => [ 111 | 'queryOrFragment' => 'f%61r=b%61r', 112 | 'expectedQuery' => 'f%61r=b%61r', 113 | ], 114 | ]; 115 | } 116 | 117 | /** 118 | * @dataProvider filterPortInvalidPortDataProvider 119 | * 120 | * @param int $port 121 | */ 122 | public function testFilterPortInvalidPort(int $port) 123 | { 124 | $this->expectException(\InvalidArgumentException::class); 125 | Filter::filterPort($port); 126 | } 127 | 128 | public function filterPortInvalidPortDataProvider(): array 129 | { 130 | return [ 131 | 'less than min' => [ 132 | 'port' => Filter::MIN_PORT - 1, 133 | ], 134 | 'greater than max' => [ 135 | 'port' => Filter::MAX_PORT + 1, 136 | ], 137 | ]; 138 | } 139 | 140 | /** 141 | * @dataProvider filterPortSuccessDataProvider 142 | * 143 | * @param $port 144 | * @param int|null $expectedPort 145 | */ 146 | public function testFilterPortSuccess($port, ?int $expectedPort) 147 | { 148 | $this->assertSame($expectedPort, Filter::filterPort($port)); 149 | } 150 | 151 | public function filterPortSuccessDataProvider(): array 152 | { 153 | return [ 154 | 'null' => [ 155 | 'port' => null, 156 | 'expectedPort' => null, 157 | ], 158 | 'int in range' => [ 159 | 'port' => 8080, 160 | 'expectedPort' => 8080, 161 | ], 162 | 'string in range' => [ 163 | 'port' => '443', 164 | 'expectedPort' => 443, 165 | ], 166 | ]; 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /tests/HostTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($hostname, (string)$host); 20 | } 21 | 22 | public function createDataProvider(): array 23 | { 24 | return [ 25 | 'domain name' => [ 26 | 'hostname' => 'example.com', 27 | ], 28 | 'IPv4' => [ 29 | 'hostname' => '192.168.0.1', 30 | ], 31 | ]; 32 | } 33 | 34 | /** 35 | * @dataProvider getPartsDataProvider 36 | * 37 | * @param string $hostname 38 | * @param string[]|int[] $expectedParts 39 | */ 40 | public function testGetParts(string $hostname, array $expectedParts) 41 | { 42 | $host = new Host($hostname); 43 | 44 | $this->assertEquals($expectedParts, $host->getParts()); 45 | } 46 | 47 | public function getPartsDataProvider(): array 48 | { 49 | return [ 50 | 'foo' => [ 51 | 'hostname' => 'foo', 52 | 'expectedParts' => [ 53 | 'foo', 54 | ], 55 | ], 56 | 'example.com' => [ 57 | 'hostname' => 'example.com', 58 | 'expectedParts' => [ 59 | 'example', 60 | 'com', 61 | ], 62 | ], 63 | 'example.co.uk' => [ 64 | 'hostname' => 'example.co.uk', 65 | 'expectedParts' => [ 66 | 'example', 67 | 'co', 68 | 'uk', 69 | ], 70 | ], 71 | '192.168.0.1' => [ 72 | 'hostname' => '192.168.0.1', 73 | 'expectedParts' => [ 74 | 192, 75 | 168, 76 | 0, 77 | 1, 78 | ], 79 | ], 80 | ]; 81 | } 82 | 83 | /** 84 | * @dataProvider equalsDataProvider 85 | * 86 | * @param string $hostname 87 | * @param string $comparatorHostname 88 | * @param bool $expectedEquals 89 | */ 90 | public function testEquals(string $hostname, string $comparatorHostname, bool $expectedEquals) 91 | { 92 | $host = new Host($hostname); 93 | $comparator = new Host($comparatorHostname); 94 | 95 | $this->assertEquals($expectedEquals, $host->equals($comparator)); 96 | $this->assertEquals($expectedEquals, $comparator->equals($host)); 97 | } 98 | 99 | public function equalsDataProvider(): array 100 | { 101 | return [ 102 | 'example.com == example.com' => [ 103 | 'hostname' => 'example.com', 104 | 'comparatorHostname' => 'example.com', 105 | 'expectedEquals' => true, 106 | ], 107 | 'example.com != foo.example.com' => [ 108 | 'hostname' => 'example.com', 109 | 'comparatorHostname' => 'foo.example.com', 110 | 'expectedEquals' => false, 111 | ], 112 | ]; 113 | } 114 | 115 | /** 116 | * @dataProvider isEquivalentToDataProvider 117 | * 118 | * @param string $hostname 119 | * @param string $comparatorHostname 120 | * @param string[] $excludeParts 121 | * @param bool $expectedIsEquivalentTo 122 | */ 123 | public function testIsEquivalentTo( 124 | string $hostname, 125 | string $comparatorHostname, 126 | array $excludeParts, 127 | bool $expectedIsEquivalentTo 128 | ) { 129 | $host = new Host($hostname); 130 | $comparator = new Host($comparatorHostname); 131 | 132 | $this->assertEquals($expectedIsEquivalentTo, $host->isEquivalentTo($comparator, $excludeParts)); 133 | } 134 | 135 | public function isEquivalentToDataProvider(): array 136 | { 137 | return [ 138 | 'example.com is equivalent to example.com' => [ 139 | 'hostname' => 'example.com', 140 | 'comparatorHostname' => 'example.com', 141 | 'excludeParts' => [], 142 | 'expectedIsEquivalentTo' => true, 143 | ], 144 | 'example.com is not equivalent to www.example.com' => [ 145 | 'hostname' => 'example.com', 146 | 'comparatorHostname' => 'www.example.com', 147 | 'excludeParts' => [], 148 | 'expectedIsEquivalentTo' => false, 149 | ], 150 | 'example.com is equivalent to www.example.com when excluding www part' => [ 151 | 'hostname' => 'example.com', 152 | 'comparatorHostname' => 'www.example.com', 153 | 'excludeParts' => [ 154 | 'www', 155 | ], 156 | 'expectedIsEquivalentTo' => true, 157 | ], 158 | 'idn equivalence 1' => [ 159 | 'hostname' => 'econom.ía.com', 160 | 'comparatorHostname' => 'econom.xn--a-iga.com', 161 | 'excludeParts' => [], 162 | 'expectedIsEquivalentTo' => true, 163 | ], 164 | 'idn equivalence 2' => [ 165 | 'hostname' => 'ヒキワリ.ナットウ.ニホン', 166 | 'comparatorHostname' => 'xn--nckwd5cta.xn--gckxcpg.xn--idk6a7d', 167 | 'excludeParts' => [], 168 | 'expectedIsEquivalentTo' => true, 169 | ], 170 | 'idn equivalence 3' => [ 171 | 'hostname' => 'транспорт.com', 172 | 'comparatorHostname' => 'xn--80a0addceeeh.com', 173 | 'excludeParts' => [], 174 | 'expectedIsEquivalentTo' => true, 175 | ], 176 | ]; 177 | } 178 | 179 | /** 180 | * 0.0.0.0/8 "This" Network RFC 1122, Section 3.2.1.3 181 | * 10.0.0.0/8 Private-Use Networks RFC 1918 182 | * 127.0.0.0/8 Loopback RFC 1122, Section 3.2.1.3 183 | * 169.254.0.0/16 Link Local RFC 3927 184 | * 172.16.0.0/12 Private-Use Networks RFC 1918 185 | * 192.0.0.0/24 IETF Protocol Assignments RFC 5736 186 | * 192.0.2.0/24 TEST-NET-1 RFC 5737 187 | * 192.88.99.0/24 6to4 Relay Anycast RFC 3068 188 | * 192.168.0.0/16 Private-Use Networks RFC 1918 189 | * 198.18.0.0/15 Network Interconnect Device Benchmark Testing RFC 2544 190 | * 198.51.100.0/24 TEST-NET-2 RFC 5737 191 | * 203.0.113.0/24 TEST-NET-3 RFC 5737 192 | * 224.0.0.0/4 Multicast RFC 3171 193 | * 240.0.0.0/4 Reserved for Future Use RFC 1112, Section 4 194 | * 255.255.255.255/32 Limited Broadcast RFC 919, Section 7 RFC 922, Section 7 195 | * 196 | * @dataProvider ipRangeIsPubliclyRoutableDataProvider 197 | * 198 | * @param string $ipRange 199 | * @param bool $expectedIsPubliclyRoutable 200 | * 201 | * @throws InvalidExpressionException 202 | */ 203 | public function testIpRangeIsPubliclyRoutable(string $ipRange, bool $expectedIsPubliclyRoutable) 204 | { 205 | $ipRangeSplit = explode('/', $ipRange); 206 | 207 | $startIp = $ipRangeSplit[0]; 208 | $cidrRange = (int)$ipRangeSplit[1]; 209 | $ipCount = 1 << (32 - $cidrRange); 210 | 211 | $firstIpInRange = ip2long($startIp); 212 | $lastIpInRange = $firstIpInRange + $ipCount - 1; 213 | 214 | $ipsToTest = array_merge( 215 | [ 216 | $firstIpInRange, 217 | $lastIpInRange 218 | ], 219 | $this->getRandomLongIpSubsetInRange($firstIpInRange, $lastIpInRange) 220 | ); 221 | 222 | foreach ($ipsToTest as $longIp) { 223 | $host = new Host(long2ip($longIp)); 224 | 225 | $this->assertEquals($expectedIsPubliclyRoutable, $host->isPubliclyRoutable()); 226 | } 227 | } 228 | 229 | /** 230 | * @throws InvalidExpressionException 231 | */ 232 | public function testLoopbackIpIsNotPubliclyRoutable() 233 | { 234 | $host = new Host('127.0.0.1'); 235 | 236 | $this->assertFalse($host->isPubliclyRoutable()); 237 | } 238 | 239 | /** 240 | * @throws InvalidExpressionException 241 | */ 242 | public function testDomainNameIsPubliclyRoutable() 243 | { 244 | $host = new Host('foo'); 245 | 246 | $this->assertTrue($host->isPubliclyRoutable()); 247 | } 248 | 249 | public function ipRangeIsPubliclyRoutableDataProvider(): array 250 | { 251 | return [ 252 | '0.0.0.0/8 is not publicly routable' => [ 253 | 'ipRange' => '0.0.0.0/8', 254 | 'expectedIsPubliclyRoutable' => false, 255 | ], 256 | '1.0.0.0/8 is publicly routable' => [ 257 | 'ipRange' => '1.0.0.0/8', 258 | 'expectedIsPubliclyRoutable' => true, 259 | ], 260 | '2.0.0.0/8 is publicly routable' => [ 261 | 'ipRange' => '2.0.0.0/8', 262 | 'expectedIsPubliclyRoutable' => true, 263 | ], 264 | '10.0.0.0/8 is not publicly routable' => [ 265 | 'ipRange' => '10.0.0.0/8', 266 | 'expectedIsPubliclyRoutable' => false, 267 | ], 268 | '127.0.0.0/8 is not publicly routable' => [ 269 | 'ipRange' => '127.0.0.0/8', 270 | 'expectedIsPubliclyRoutable' => false, 271 | ], 272 | '169.254.0.0/16 is not publicly routable' => [ 273 | 'ipRange' => '169.254.0.0/16', 274 | 'expectedIsPubliclyRoutable' => false, 275 | ], 276 | '172.16.0.0/12 is not publicly routable' => [ 277 | 'ipRange' => '172.16.0.0/12', 278 | 'expectedIsPubliclyRoutable' => false, 279 | ], 280 | '192.0.0.0/24 is not publicly routable' => [ 281 | 'ipRange' => '192.0.0.0/24', 282 | 'expectedIsPubliclyRoutable' => false, 283 | ], 284 | '192.0.2.0/24 is not publicly routable' => [ 285 | 'ipRange' => '192.0.2.0/24', 286 | 'expectedIsPubliclyRoutable' => false, 287 | ], 288 | '192.88.99.0/24 is not publicly routable' => [ 289 | 'ipRange' => '192.88.99.0/24', 290 | 'expectedIsPubliclyRoutable' => false, 291 | ], 292 | '192.168.0.0/16 is not publicly routable' => [ 293 | 'ipRange' => '192.168.0.0/16', 294 | 'expectedIsPubliclyRoutable' => false, 295 | ], 296 | '198.18.0.0/15 is not publicly routable' => [ 297 | 'ipRange' => '198.18.0.0/15', 298 | 'expectedIsPubliclyRoutable' => false, 299 | ], 300 | '198.51.100.0/24 is not publicly routable' => [ 301 | 'ipRange' => '198.51.100.0/24', 302 | 'expectedIsPubliclyRoutable' => false, 303 | ], 304 | '203.0.113.0/24 is not publicly routable' => [ 305 | 'ipRange' => '203.0.113.0/24', 306 | 'expectedIsPubliclyRoutable' => false, 307 | ], 308 | '224.0.0.0/4 is not publicly routable' => [ 309 | 'ipRange' => '224.0.0.0/4', 310 | 'expectedIsPubliclyRoutable' => false, 311 | ], 312 | '240.0.0.0/4 is not publicly routable' => [ 313 | 'ipRange' => '240.0.0.0/4', 314 | 'expectedIsPubliclyRoutable' => false, 315 | ], 316 | '255.255.255.255/32 is not publicly routable' => [ 317 | 'ipRange' => '255.255.255.255/32', 318 | 'expectedIsPubliclyRoutable' => false, 319 | ], 320 | ]; 321 | } 322 | 323 | /** 324 | * @param int $first 325 | * @param int $last 326 | * @param int $max 327 | * 328 | * @return array 329 | */ 330 | private function getRandomLongIpSubsetInRange(int $first, int $last, int $max = 32) 331 | { 332 | $ips = array(); 333 | 334 | while (count($ips) < $max) { 335 | $ips[] = rand($first, $last); 336 | } 337 | 338 | return $ips; 339 | } 340 | } 341 | -------------------------------------------------------------------------------- /tests/InspectorTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($expectedIsPubliclyRoutable, Inspector::isNotPubliclyRoutable($url)); 23 | } 24 | 25 | public function isNotPubliclyRoutableDataProvider(): array 26 | { 27 | return [ 28 | 'no host' => [ 29 | 'url' => new Url('example'), 30 | 'expectedIsPubliclyRoutable' => true, 31 | ], 32 | 'host not publicly routable' => [ 33 | 'url' => new Url('http://127.0.0.1'), 34 | 'expectedIsPubliclyRoutable' => true, 35 | ], 36 | 'host lacks dots' => [ 37 | 'url' => new Url('http://example'), 38 | 'expectedIsPubliclyRoutable' => true, 39 | ], 40 | 'host starts with dot' => [ 41 | 'url' => new Url('http://.example'), 42 | 'expectedIsPubliclyRoutable' => true, 43 | ], 44 | 'host ends with dot' => [ 45 | 'url' => new Url('http://example.'), 46 | 'expectedIsPubliclyRoutable' => true, 47 | ], 48 | 'valid' => [ 49 | 'url' => new Url('http://example.com'), 50 | 'expectedIsPubliclyRoutable' => false, 51 | ], 52 | ]; 53 | } 54 | 55 | /** 56 | * @dataProvider isProtocolRelativeDataProvider 57 | * 58 | * @param UriInterface $uri 59 | * @param bool $expectedIsProtocolRelative 60 | */ 61 | public function testIsProtocolRelative(UriInterface $uri, bool $expectedIsProtocolRelative) 62 | { 63 | $this->assertSame($expectedIsProtocolRelative, Inspector::isProtocolRelative($uri)); 64 | } 65 | 66 | public function isProtocolRelativeDataProvider(): array 67 | { 68 | return [ 69 | 'no scheme, no host is not protocol relative' => [ 70 | 'uri' => new Url('/path'), 71 | 'expectedIsProtocolRelative' => false, 72 | ], 73 | 'has scheme is not protocol relative' => [ 74 | 'uri' => new Url('http://example'), 75 | 'expectedIsProtocolRelative' => false, 76 | ], 77 | 'no scheme has host is protocol relative' => [ 78 | 'uri' => new Url('//example.com'), 79 | 'expectedIsProtocolRelative' => true, 80 | ], 81 | ]; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /tests/NormalizerTest.php: -------------------------------------------------------------------------------- 1 | assertEquals((string) $expectedUrl, (string) $normalizedUrl); 45 | } 46 | 47 | public function removeUserInfoDataProvider(): array 48 | { 49 | return [ 50 | 'removeUserInfo: no user info' => [ 51 | 'url' => 'https://example.com', 52 | 'expectedUrl' => 'https://example.com', 53 | 'flags' => Normalizer::REMOVE_USER_INFO, 54 | ], 55 | 'removeUserInfo: has user info' => [ 56 | 'url' => 'https://user:password@example.com', 57 | 'expectedUrl' => 'https://example.com', 58 | 'flags' => Normalizer::REMOVE_USER_INFO, 59 | ], 60 | ]; 61 | } 62 | 63 | public function hostNormalizationDataProvider(): array 64 | { 65 | return [ 66 | 'convertHostUnicodeToPunycode: normal host' => [ 67 | 'url' => 'https://example.com', 68 | 'expectedUrl' => 'https://example.com', 69 | 'flags' => Normalizer::CONVERT_HOST_UNICODE_TO_PUNYCODE, 70 | ], 71 | 'convertHostUnicodeToPunycode: punycode host' => [ 72 | 'url' => 'https://artesan.xn--a-iga.com', 73 | 'expectedUrl' => 'https://artesan.xn--a-iga.com', 74 | 'flags' => Normalizer::CONVERT_HOST_UNICODE_TO_PUNYCODE, 75 | ], 76 | 'convertHostUnicodeToPunycode: unicode host' => [ 77 | 'url' => 'https://artesan.ía.com', 78 | 'expectedUrl' => 'https://artesan.xn--a-iga.com', 79 | 'flags' => Normalizer::CONVERT_HOST_UNICODE_TO_PUNYCODE, 80 | ], 81 | ]; 82 | } 83 | 84 | public function removeFragmentDataProvider(): array 85 | { 86 | return [ 87 | 'removeFragment:, no fragment' => [ 88 | 'url' => 'http://example.com', 89 | 'expectedUrl' => 'http://example.com', 90 | 'flags' => Normalizer::REMOVE_FRAGMENT, 91 | ], 92 | 'removeFragment:, has fragment' => [ 93 | 'url' => 'http://example.com#foo', 94 | 'expectedUrl' => 'http://example.com', 95 | 'flags' => Normalizer::REMOVE_FRAGMENT, 96 | ], 97 | ]; 98 | } 99 | 100 | public function removeWwwDataProvider(): array 101 | { 102 | return [ 103 | 'removeWww: no www' => [ 104 | 'url' => 'http://example.com', 105 | 'expectedUrl' => 'http://example.com', 106 | 'flags' => Normalizer::REMOVE_WWW, 107 | ], 108 | 'removeWww: has www' => [ 109 | 'url' => 'http://www.example.com', 110 | 'expectedUrl' => 'http://example.com', 111 | 'flags' => Normalizer::REMOVE_WWW, 112 | ], 113 | ]; 114 | } 115 | 116 | public function removePathFilesDataProvider(): array 117 | { 118 | $patterns = [ 119 | Normalizer::REMOVE_INDEX_FILE_PATTERN, 120 | ]; 121 | 122 | $options = [ 123 | Normalizer::OPTION_REMOVE_PATH_FILES_PATTERNS => $patterns, 124 | ]; 125 | 126 | return [ 127 | 'removePathFilesPatterns: empty path' => [ 128 | 'url' => 'http://example.com', 129 | 'expectedUrl' => 'http://example.com', 130 | 'flags' => Normalizer::NONE, 131 | 'options' => $options, 132 | ], 133 | 'removePathFilesPatterns: no filename' => [ 134 | 'url' => 'http://example.com/', 135 | 'expectedUrl' => 'http://example.com/', 136 | 'flags' => Normalizer::NONE, 137 | 'options' => $options, 138 | ], 139 | 'removePathFilesPatterns: foo-index.html' => [ 140 | 'url' => 'http://example.com/foo-index.html', 141 | 'expectedUrl' => 'http://example.com/foo-index.html', 142 | 'flags' => Normalizer::NONE, 143 | 'options' => $options, 144 | ], 145 | 'removePathFilesPatterns: index-foo.html' => [ 146 | 'url' => 'http://example.com/index-foo.html', 147 | 'expectedUrl' => 'http://example.com/index-foo.html', 148 | 'flags' => Normalizer::NONE, 149 | 'options' => $options, 150 | ], 151 | 'removePathFilesPatterns: index.html' => [ 152 | 'url' => 'http://example.com/index.html', 153 | 'expectedUrl' => 'http://example.com', 154 | 'flags' => Normalizer::NONE, 155 | 'options' => $options, 156 | ], 157 | 'removePathFilesPatterns: index.js' => [ 158 | 'url' => 'http://example.com/index.js', 159 | 'expectedUrl' => 'http://example.com', 160 | 'flags' => Normalizer::NONE, 161 | 'options' => $options, 162 | ], 163 | ]; 164 | } 165 | 166 | public function removeDotPathSegmentsDataProvider(): array 167 | { 168 | return [ 169 | 'removeDotPathSegments: no path' => [ 170 | 'url' => 'http://example.com', 171 | 'expectedUrl' => 'http://example.com', 172 | 'flags' => Normalizer::REMOVE_PATH_DOT_SEGMENTS, 173 | ], 174 | 'removeDotPathSegments: / path' => [ 175 | 'url' => 'http://example.com/', 176 | 'expectedUrl' => 'http://example.com/', 177 | 'flags' => Normalizer::REMOVE_PATH_DOT_SEGMENTS, 178 | ], 179 | 'removeDotPathSegments: single dot' => [ 180 | 'url' => 'http://example.com/.', 181 | 'expectedUrl' => 'http://example.com/', 182 | 'flags' => Normalizer::REMOVE_PATH_DOT_SEGMENTS, 183 | ], 184 | 'removeDotPathSegments: double dot' => [ 185 | 'url' => 'http://example.com/..', 186 | 'expectedUrl' => 'http://example.com/', 187 | 'flags' => Normalizer::REMOVE_PATH_DOT_SEGMENTS, 188 | ], 189 | 'removeDotPathSegments: rfc3986 5.2.4 example 1' => [ 190 | 'url' => 'http://example.com/a/b/c/./../../g', 191 | 'expectedUrl' => 'http://example.com/a/g', 192 | 'flags' => Normalizer::REMOVE_PATH_DOT_SEGMENTS, 193 | ], 194 | 'removeDotPathSegments: rfc3986 5.2.4 example 2' => [ 195 | 'url' => 'http://example.com/mid/content=5/../6', 196 | 'expectedUrl' => 'http://example.com/mid/6', 197 | 'flags' => Normalizer::REMOVE_PATH_DOT_SEGMENTS, 198 | ], 199 | 'removeDotPathSegments: many single dot' => [ 200 | 'url' => 'http://example.com/././././././././././././././.', 201 | 'expectedUrl' => 'http://example.com', 202 | 'flags' => Normalizer::REMOVE_PATH_DOT_SEGMENTS, 203 | ], 204 | 'removeDotPathSegments: many single dot, trailing slash' => [ 205 | 'url' => 'http://example.com/./././././././././././././././', 206 | 'expectedUrl' => 'http://example.com/', 207 | 'flags' => Normalizer::REMOVE_PATH_DOT_SEGMENTS, 208 | ], 209 | 'removeDotPathSegments: many double dot' => [ 210 | 'url' => 'http://example.com/../../../../../..', 211 | 'expectedUrl' => 'http://example.com', 212 | 'flags' => Normalizer::REMOVE_PATH_DOT_SEGMENTS, 213 | ], 214 | 'removeDotPathSegments: many double dot, trailing slash' => [ 215 | 'url' => 'http://example.com/../../../../../../', 216 | 'expectedUrl' => 'http://example.com/', 217 | 'flags' => Normalizer::REMOVE_PATH_DOT_SEGMENTS, 218 | ], 219 | ]; 220 | } 221 | 222 | public function addTrailingSlashDataProvider(): array 223 | { 224 | return [ 225 | 'addTrailingSlash: no path, no trailing slash' => [ 226 | 'url' => 'http://example.com', 227 | 'expectedUrl' => 'http://example.com/', 228 | 'flags' => Normalizer::ADD_PATH_TRAILING_SLASH, 229 | ], 230 | 'addTrailingSlash: has path, no trailing slash' => [ 231 | 'url' => 'http://example.com/foo', 232 | 'expectedUrl' => 'http://example.com/foo/', 233 | 'flags' => Normalizer::ADD_PATH_TRAILING_SLASH, 234 | ], 235 | 'addTrailingSlash: empty path, has trailing slash' => [ 236 | 'url' => 'http://example.com/', 237 | 'expectedUrl' => 'http://example.com/', 238 | 'flags' => Normalizer::ADD_PATH_TRAILING_SLASH, 239 | ], 240 | 'addTrailingSlash: has path, has trailing slash' => [ 241 | 'url' => 'http://example.com/foo/', 242 | 'expectedUrl' => 'http://example.com/foo/', 243 | 'flags' => Normalizer::ADD_PATH_TRAILING_SLASH, 244 | ], 245 | 'addTrailingSlash: has filename' => [ 246 | 'url' => 'http://example.com/index.html', 247 | 'expectedUrl' => 'http://example.com/index.html', 248 | 'flags' => Normalizer::ADD_PATH_TRAILING_SLASH, 249 | ], 250 | ]; 251 | } 252 | 253 | public function sortQueryParametersDataProvider(): array 254 | { 255 | return [ 256 | 'sortQueryParameters: no query' => [ 257 | 'url' => 'http://example.com', 258 | 'expectedUrl' => 'http://example.com', 259 | 'flags' => Normalizer::SORT_QUERY_PARAMETERS, 260 | ], 261 | 'sortQueryParameters: has query' => [ 262 | 'url' => 'http://example.com?b=bear&a=apple&c=cow', 263 | 'expectedUrl' => 'http://example.com?a=apple&b=bear&c=cow', 264 | 'flags' => Normalizer::SORT_QUERY_PARAMETERS, 265 | ], 266 | 'sortQueryParameters: key without value' => [ 267 | 'url' => 'http://example.com?key2&key1=value1', 268 | 'expectedUrl' => 'http://example.com?key1=value1&key2', 269 | 'flags' => Normalizer::SORT_QUERY_PARAMETERS, 270 | ], 271 | ]; 272 | } 273 | 274 | public function reduceDuplicatePathSlashesDataProvider(): array 275 | { 276 | return [ 277 | 'reduceDuplicatePathSlashes: no path' => [ 278 | 'url' => 'http://example.com', 279 | 'expectedUrl' => 'http://example.com', 280 | 'flags' => Normalizer::REDUCE_DUPLICATE_PATH_SLASHES, 281 | ], 282 | 'reduceDuplicatePathSlashes: no duplicate slashes' => [ 283 | 'url' => 'http://example.com/path', 284 | 'expectedUrl' => 'http://example.com/path', 285 | 'flags' => Normalizer::REDUCE_DUPLICATE_PATH_SLASHES, 286 | ], 287 | 'reduceDuplicatePathSlashes: has duplicate slashes' => [ 288 | 'url' => 'http://example.com//path//', 289 | 'expectedUrl' => 'http://example.com//path//', 290 | 'flags' => Normalizer::REDUCE_DUPLICATE_PATH_SLASHES, 291 | ], 292 | ]; 293 | } 294 | 295 | public function decodeUnreservedCharactersDataProvider() : array 296 | { 297 | $characters = $this->createUnreservedCharactersString(); 298 | $percentEncodedCharacters = $this->percentEncodeString($characters); 299 | 300 | return [ 301 | 'decodeUnreservedCharacters: ' => [ 302 | 'url' => 'http://example.com/' . $percentEncodedCharacters, 303 | 'expectedUrl' => 'http://example.com/' . $characters, 304 | 'flags' => Normalizer::DECODE_UNRESERVED_CHARACTERS, 305 | ], 306 | ]; 307 | } 308 | 309 | public function removeDefaultPortDataProvider(): array 310 | { 311 | return [ 312 | 'removeDefaultPort: http url with port 80' => [ 313 | 'url' => $this->setUrlPort('http://example.com:80', 80), 314 | 'expectedUrl' => 'http://example.com', 315 | 'flags' => Normalizer::REMOVE_DEFAULT_PORT, 316 | ], 317 | 'removeDefaultPort: https url with port 443' => [ 318 | 'url' => $this->setUrlPort('https://example.com:443', 443), 319 | 'expectedUrl' => 'https://example.com', 320 | 'flags' => Normalizer::REMOVE_DEFAULT_PORT, 321 | ], 322 | ]; 323 | } 324 | 325 | public function capitalizePercentEncodingDataProvider(): array 326 | { 327 | $characters = $this->createUnreservedCharactersString(); 328 | $percentEncodedCharacters = $this->percentEncodeString($characters); 329 | 330 | return [ 331 | 'capitalizePercentEncoding: lowercase' => [ 332 | 'url' => 'http://example.com/' . strtolower($percentEncodedCharacters), 333 | 'expectedUrl' => 'http://example.com/' . $percentEncodedCharacters, 334 | 'flags' => Normalizer::CAPITALIZE_PERCENT_ENCODING, 335 | ], 336 | 'capitalizePercentEncoding: uppercase' => [ 337 | 'url' => 'http://example.com/' . $percentEncodedCharacters, 338 | 'expectedUrl' => 'http://example.com/' . $percentEncodedCharacters, 339 | 'flags' => Normalizer::CAPITALIZE_PERCENT_ENCODING, 340 | ], 341 | ]; 342 | } 343 | 344 | public function convertEmptyHttpPathDataProvider(): array 345 | { 346 | return [ 347 | 'convertEmptyHttpPath: http' => [ 348 | 'url' => 'http://example.com', 349 | 'expectedUrl' => 'http://example.com/', 350 | 'flags' => Normalizer::CONVERT_EMPTY_HTTP_PATH, 351 | ], 352 | 'convertEmptyHttpPath: https' => [ 353 | 'url' => 'https://example.com', 354 | 'expectedUrl' => 'https://example.com/', 355 | 'flags' => Normalizer::CONVERT_EMPTY_HTTP_PATH, 356 | ], 357 | ]; 358 | } 359 | 360 | public function removeDefaultFileHostDataProvider(): array 361 | { 362 | return [ 363 | 'removeDefaultFileHost: http' => [ 364 | 'url' => 'file://localhost/path', 365 | 'expectedUrl' => 'file:///path', 366 | 'flags' => Normalizer::REMOVE_DEFAULT_FILE_HOST, 367 | ], 368 | ]; 369 | } 370 | 371 | public function removeQueryParametersDataProvider(): array 372 | { 373 | $url = 'http://example.com/?foo=bar&fizz=buzz&foobar&fizzbuzz'; 374 | 375 | return [ 376 | 'removeQueryParameters: patterns not an array' => [ 377 | 'url' => $url, 378 | 'expectedUrl' => $url, 379 | 'flags' => Normalizer::NONE, 380 | 'options' => [ 381 | Normalizer::OPTION_REMOVE_QUERY_PARAMETERS_PATTERNS => false, 382 | ], 383 | ], 384 | 'removeQueryParameters: empty patterns' => [ 385 | 'url' => $url, 386 | 'expectedUrl' => $url, 387 | 'flags' => Normalizer::NONE, 388 | 'options' => [ 389 | Normalizer::OPTION_REMOVE_QUERY_PARAMETERS_PATTERNS => [], 390 | ], 391 | ], 392 | 'removeQueryParameters: non-empty patterns (1)' => [ 393 | 'url' => $url, 394 | 'expectedUrl' => 'http://example.com/', 395 | 'flags' => Normalizer::NONE, 396 | 'options' => [ 397 | Normalizer::OPTION_REMOVE_QUERY_PARAMETERS_PATTERNS => [ 398 | '/^f[a-z]+$/' 399 | ], 400 | ], 401 | ], 402 | 'removeQueryParameters: non-empty patterns (2)' => [ 403 | 'url' => $url, 404 | 'expectedUrl' => 'http://example.com/?fizz=buzz&fizzbuzz', 405 | 'flags' => Normalizer::NONE, 406 | 'options' => [ 407 | Normalizer::OPTION_REMOVE_QUERY_PARAMETERS_PATTERNS => [ 408 | '/^foo/' 409 | ], 410 | ], 411 | ], 412 | 'removeQueryParameters: non-empty patterns (3)' => [ 413 | 'url' => $url, 414 | 'expectedUrl' => 'http://example.com/?foobar&fizzbuzz', 415 | 'flags' => Normalizer::NONE, 416 | 'options' => [ 417 | Normalizer::OPTION_REMOVE_QUERY_PARAMETERS_PATTERNS => [ 418 | '/^(foo|fizz)$/' 419 | ], 420 | ], 421 | ], 422 | ]; 423 | } 424 | 425 | public function defaultsDataProvider(): array 426 | { 427 | $unreservedCharacters = $this->createUnreservedCharactersString(); 428 | $percentEncodedUnreservedCharacters = $this->percentEncodeString($unreservedCharacters); 429 | 430 | return [ 431 | 'default: default scheme is not set if missing' => [ 432 | 'url' => '//example.com/', 433 | 'expectedUrl' => '//example.com/', 434 | ], 435 | 'default: http is not forced' => [ 436 | 'url' => 'https://example.com/', 437 | 'expectedUrl' => 'https://example.com/', 438 | ], 439 | 'default: https is not forced' => [ 440 | 'url' => 'http://example.com/', 441 | 'expectedUrl' => 'http://example.com/', 442 | ], 443 | 'default: user info is not removed' => [ 444 | 'url' => 'http://user:password@example.com/', 445 | 'expectedUrl' => 'http://user:password@example.com/', 446 | ], 447 | 'default: unicode in domain is not converted to punycode' => [ 448 | 'url' => 'http://♥.example.com/', 449 | 'expectedUrl' => 'http://xn--g6h.example.com/', 450 | ], 451 | 'default: fragment is not removed' => [ 452 | 'url' => 'http://example.com/#fragment', 453 | 'expectedUrl' => 'http://example.com/#fragment', 454 | ], 455 | 'default: www is not removed' => [ 456 | 'url' => 'http://www.example.com/', 457 | 'expectedUrl' => 'http://www.example.com/', 458 | ], 459 | 'default: path dot segments are removed' => [ 460 | 'url' => 'http://example.com/././.', 461 | 'expectedUrl' => 'http://example.com/', 462 | ], 463 | 'default: path trailing slash is not added' => [ 464 | 'url' => 'http://example.com/path', 465 | 'expectedUrl' => 'http://example.com/path', 466 | ], 467 | 'default: duplicate path slashes are not reduced' => [ 468 | 'url' => 'http://example.com//path//', 469 | 'expectedUrl' => 'http://example.com//path//', 470 | ], 471 | 'default: query parameters are not sorted' => [ 472 | 'url' => 'http://example.com/?b=2&a=1', 473 | 'expectedUrl' => 'http://example.com/?b=2&a=1', 474 | ], 475 | 'default: unreserved characters are decoded' => [ 476 | 'url' => 'http://example.com/' . $percentEncodedUnreservedCharacters, 477 | 'expectedUrl' => 'http://example.com/' . $unreservedCharacters, 478 | ], 479 | 'default: default port is removed' => [ 480 | 'url' => $this->setUrlPort('http://example.com:80/', 80), 481 | 'expectedUrl' => 'http://example.com/', 482 | ], 483 | 'default: percent encoding is capitalized' => [ 484 | 'url' => 'http://example.com/?%2f', 485 | 'expectedUrl' => 'http://example.com/?%2F', 486 | ], 487 | 'default: empty http path is converted' => [ 488 | 'url' => 'http://example.com', 489 | 'expectedUrl' => 'http://example.com/', 490 | ], 491 | 'default: empty https path is converted' => [ 492 | 'url' => 'https://example.com', 493 | 'expectedUrl' => 'https://example.com/', 494 | ], 495 | 'default: file localhost is removed' => [ 496 | 'url' => 'file://localhost/path', 497 | 'expectedUrl' => 'file:///path', 498 | ], 499 | ]; 500 | } 501 | 502 | private function createUnreservedCharactersString(): string 503 | { 504 | return strtoupper(self::ALPHA_CHARACTERS) 505 | . self::ALPHA_CHARACTERS 506 | . self::NUMERIC_CHARACTERS 507 | . self::UNRESERVED_NON_ALPHA_NUMERIC_CHARACTERS; 508 | } 509 | 510 | private function percentEncodeString(string $value): string 511 | { 512 | $charactersAsArray = str_split($value); 513 | 514 | array_walk($charactersAsArray, function (string &$character) { 515 | $character = '%' . strtoupper(dechex(ord($character))); 516 | }); 517 | 518 | return implode('', $charactersAsArray); 519 | } 520 | 521 | private function setUrlPort(string $url, int $port): string 522 | { 523 | $urlObject = new Url($url); 524 | 525 | try { 526 | $reflector = new \ReflectionClass(Url::class); 527 | $property = $reflector->getProperty('port'); 528 | $property->setAccessible(true); 529 | $property->setValue($urlObject, $port); 530 | } catch (\ReflectionException $exception) { 531 | } 532 | 533 | return (string) $urlObject; 534 | } 535 | } 536 | -------------------------------------------------------------------------------- /tests/ParserTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($expectedComponents, $components); 22 | } 23 | 24 | public function parseDataProvider(): array 25 | { 26 | return [ 27 | 'empty' => [ 28 | 'url' => '', 29 | 'expectedComponents' => [], 30 | ], 31 | 'complete fully qualified' => [ 32 | 'url' => 'http://user:pass@example.com:8080/path1/path2/filename.extension?foo=bar#fragment', 33 | 'expectedComponents' => [ 34 | Parser::COMPONENT_SCHEME => 'http', 35 | Parser::COMPONENT_HOST => 'example.com', 36 | Parser::COMPONENT_PORT => 8080, 37 | Parser::COMPONENT_USER => 'user', 38 | Parser::COMPONENT_PASS => 'pass', 39 | Parser::COMPONENT_PATH => '/path1/path2/filename.extension', 40 | Parser::COMPONENT_QUERY => 'foo=bar', 41 | Parser::COMPONENT_FRAGMENT => 'fragment', 42 | ], 43 | ], 44 | 'complete protocol-relative' => [ 45 | 'url' => '//user:pass@example.com:8080/path1/path2/filename.extension?foo=bar#fragment', 46 | 'expectedComponents' => [ 47 | Parser::COMPONENT_HOST => 'example.com', 48 | Parser::COMPONENT_PORT => 8080, 49 | Parser::COMPONENT_USER => 'user', 50 | Parser::COMPONENT_PASS => 'pass', 51 | Parser::COMPONENT_PATH => '/path1/path2/filename.extension', 52 | Parser::COMPONENT_QUERY => 'foo=bar', 53 | Parser::COMPONENT_FRAGMENT => 'fragment', 54 | ], 55 | ], 56 | 'root relative' => [ 57 | 'url' => '/path1/path2/filename.extension?foo=bar#fragment', 58 | 'expectedComponents' => [ 59 | Parser::COMPONENT_PATH => '/path1/path2/filename.extension', 60 | Parser::COMPONENT_QUERY => 'foo=bar', 61 | Parser::COMPONENT_FRAGMENT => 'fragment', 62 | ], 63 | ], 64 | 'relative' => [ 65 | 'url' => 'path1/path2/filename.extension?foo=bar#fragment', 66 | 'expectedComponents' => [ 67 | Parser::COMPONENT_PATH => 'path1/path2/filename.extension', 68 | Parser::COMPONENT_QUERY => 'foo=bar', 69 | Parser::COMPONENT_FRAGMENT => 'fragment', 70 | ], 71 | ], 72 | 'hash only' => [ 73 | 'url' => '#', 74 | 'expectedComponents' => [ 75 | Parser::COMPONENT_FRAGMENT => '', 76 | ], 77 | ], 78 | 'path and hash only' => [ 79 | 'url' => '/index.html#', 80 | 'expectedComponents' => [ 81 | Parser::COMPONENT_PATH => '/index.html', 82 | Parser::COMPONENT_FRAGMENT => '', 83 | ], 84 | ], 85 | 'hash and identifier only' => [ 86 | 'url' => '#fragment', 87 | 'expectedComponents' => [ 88 | Parser::COMPONENT_FRAGMENT => 'fragment', 89 | ], 90 | ], 91 | 'scheme, no username, no password' => [ 92 | 'url' => 'https://@example.com', 93 | 'expectedComponents' => [ 94 | Parser::COMPONENT_SCHEME => 'https', 95 | Parser::COMPONENT_HOST => 'example.com', 96 | Parser::COMPONENT_USER => '', 97 | ], 98 | ], 99 | 'protocol-relative, no username, no password' => [ 100 | 'url' => '//@example.com', 101 | 'expectedComponents' => [ 102 | Parser::COMPONENT_HOST => 'example.com', 103 | Parser::COMPONENT_USER => '', 104 | ], 105 | ], 106 | 'scheme, empty username, empty password' => [ 107 | 'url' => 'https://:@example.com', 108 | 'expectedComponents' => [ 109 | Parser::COMPONENT_SCHEME => 'https', 110 | Parser::COMPONENT_HOST => 'example.com', 111 | Parser::COMPONENT_USER => '', 112 | Parser::COMPONENT_PASS => '', 113 | ], 114 | ], 115 | 'protocol-relative, empty username, empty password' => [ 116 | 'url' => '//:@example.com', 117 | 'expectedComponents' => [ 118 | Parser::COMPONENT_HOST => 'example.com', 119 | Parser::COMPONENT_USER => '', 120 | Parser::COMPONENT_PASS => '', 121 | ], 122 | ], 123 | 'scheme, empty username, has password' => [ 124 | 'url' => 'https://:password@example.com', 125 | 'expectedComponents' => [ 126 | Parser::COMPONENT_SCHEME => 'https', 127 | Parser::COMPONENT_HOST => 'example.com', 128 | Parser::COMPONENT_USER => '', 129 | Parser::COMPONENT_PASS => 'password', 130 | ], 131 | ], 132 | 'protocol-relative, empty username, has password' => [ 133 | 'url' => '//:password@example.com', 134 | 'expectedComponents' => [ 135 | Parser::COMPONENT_HOST => 'example.com', 136 | Parser::COMPONENT_USER => '', 137 | Parser::COMPONENT_PASS => 'password', 138 | ], 139 | ], 140 | 'scheme, has username, empty password' => [ 141 | 'url' => 'https://username:@example.com', 142 | 'expectedComponents' => [ 143 | Parser::COMPONENT_SCHEME => 'https', 144 | Parser::COMPONENT_HOST => 'example.com', 145 | Parser::COMPONENT_USER => 'username', 146 | Parser::COMPONENT_PASS => '', 147 | ], 148 | ], 149 | 'protocol-relative, has username, empty password' => [ 150 | 'url' => '//username:@example.com', 151 | 'expectedComponents' => [ 152 | Parser::COMPONENT_HOST => 'example.com', 153 | Parser::COMPONENT_USER => 'username', 154 | Parser::COMPONENT_PASS => '', 155 | ], 156 | ], 157 | 'scheme, has username, no password' => [ 158 | 'url' => 'https://username@example.com', 159 | 'expectedComponents' => [ 160 | Parser::COMPONENT_SCHEME => 'https', 161 | Parser::COMPONENT_HOST => 'example.com', 162 | Parser::COMPONENT_USER => 'username', 163 | ], 164 | ], 165 | 'protocol-relative, has username, no password' => [ 166 | 'url' => '//username@example.com', 167 | 'expectedComponents' => [ 168 | Parser::COMPONENT_HOST => 'example.com', 169 | Parser::COMPONENT_USER => 'username', 170 | ], 171 | ], 172 | 'scheme-only (file_' => [ 173 | 'url' => 'file://', 174 | 'expectedComponents' => [ 175 | Parser::COMPONENT_SCHEME => 'file', 176 | ], 177 | ], 178 | 'scheme-only (http)' => [ 179 | 'url' => 'http://', 180 | 'expectedComponents' => [ 181 | Parser::COMPONENT_SCHEME => 'http', 182 | ], 183 | ], 184 | ]; 185 | } 186 | 187 | public function normalizeWhitespaceDataProvider(): array 188 | { 189 | return [ 190 | 'trailing tab is removed' => [ 191 | 'url' => "http://example.com\t", 192 | 'expectedComponents' => [ 193 | Parser::COMPONENT_SCHEME => 'http', 194 | Parser::COMPONENT_HOST => 'example.com', 195 | ], 196 | ], 197 | 'trailing newline is removed' => [ 198 | 'url' => "http://example.com\n", 199 | 'expectedComponents' => [ 200 | Parser::COMPONENT_SCHEME => 'http', 201 | Parser::COMPONENT_HOST => 'example.com', 202 | ], 203 | ], 204 | 'trailing line return' => [ 205 | 'url' => "http://example.com\r", 206 | 'expectedComponents' => [ 207 | Parser::COMPONENT_SCHEME => 'http', 208 | Parser::COMPONENT_HOST => 'example.com', 209 | ], 210 | ], 211 | 'leading tab is removed' => [ 212 | 'url' => "\thttp://example.com", 213 | 'expectedComponents' => [ 214 | Parser::COMPONENT_SCHEME => 'http', 215 | Parser::COMPONENT_HOST => 'example.com', 216 | ], 217 | ], 218 | 'leading newline is removed' => [ 219 | 'url' => "\nhttp://example.com", 220 | 'expectedComponents' => [ 221 | Parser::COMPONENT_SCHEME => 'http', 222 | Parser::COMPONENT_HOST => 'example.com', 223 | ], 224 | ], 225 | 'leading line return' => [ 226 | 'url' => "\nhttp://example.com", 227 | 'expectedComponents' => [ 228 | Parser::COMPONENT_SCHEME => 'http', 229 | Parser::COMPONENT_HOST => 'example.com', 230 | ], 231 | ], 232 | 'tab in path is removed' => [ 233 | 'url' => "http://example.com/foo\t/bar", 234 | 'expectedComponents' => [ 235 | Parser::COMPONENT_SCHEME => 'http', 236 | Parser::COMPONENT_HOST => 'example.com', 237 | Parser::COMPONENT_PATH => '/foo/bar', 238 | ], 239 | ], 240 | 'newline in path is removed' => [ 241 | 'url' => "http://example.com/foo\n/bar", 242 | 'expectedComponents' => [ 243 | Parser::COMPONENT_SCHEME => 'http', 244 | Parser::COMPONENT_HOST => 'example.com', 245 | Parser::COMPONENT_PATH => '/foo/bar', 246 | ], 247 | ], 248 | 'line return in path is removed' => [ 249 | 'url' => "http://example.com/foo\r/bar", 250 | 'expectedComponents' => [ 251 | Parser::COMPONENT_SCHEME => 'http', 252 | Parser::COMPONENT_HOST => 'example.com', 253 | Parser::COMPONENT_PATH => '/foo/bar', 254 | ], 255 | ], 256 | 'many tabs, newlines and line returns' => [ 257 | 'url' => "\n\thttp://example.com\r\n/\rpage/\t", 258 | 'expectedComponents' => [ 259 | Parser::COMPONENT_SCHEME => 'http', 260 | Parser::COMPONENT_HOST => 'example.com', 261 | Parser::COMPONENT_PATH => '/page/', 262 | ], 263 | ], 264 | ]; 265 | } 266 | 267 | public function invalidPortDataProvider(): array 268 | { 269 | return [ 270 | 'invalid port (not an integer), no path' => [ 271 | 'url' => 'http://example.com:foo', 272 | 'expectedComponents' => [], 273 | ], 274 | 'invalid port (too small), no path' => [ 275 | 'url' => 'http://example.com:0', 276 | 'expectedComponents' => [ 277 | Parser::COMPONENT_SCHEME => 'http', 278 | Parser::COMPONENT_HOST => 'example.com', 279 | Parser::COMPONENT_PORT => '0', 280 | ], 281 | ], 282 | 'invalid port (too small), protocol-relative, no path' => [ 283 | 'url' => '//example.com:0', 284 | 'expectedComponents' => [ 285 | Parser::COMPONENT_HOST => 'example.com', 286 | Parser::COMPONENT_PORT => '0', 287 | ], 288 | ], 289 | 'invalid port (too small), path only' => [ 290 | 'url' => ':0/path', 291 | 'expectedComponents' => [ 292 | Parser::COMPONENT_PORT => '0', 293 | Parser::COMPONENT_PATH => '/path', 294 | ], 295 | ], 296 | 'invalid port (too large), no path' => [ 297 | 'url' => 'http://example.com:65536', 298 | 'expectedComponents' => [ 299 | Parser::COMPONENT_SCHEME => 'http', 300 | Parser::COMPONENT_HOST => 'example.com', 301 | Parser::COMPONENT_PORT => '65536', 302 | ], 303 | ], 304 | 'invalid port (too small), with path' => [ 305 | 'url' => 'http://example.com:0/path', 306 | 'expectedComponents' => [ 307 | Parser::COMPONENT_SCHEME => 'http', 308 | Parser::COMPONENT_HOST => 'example.com', 309 | Parser::COMPONENT_PORT => '0', 310 | Parser::COMPONENT_PATH => '/path', 311 | ], 312 | ], 313 | 'invalid port (too small), with path containing port-like pattern' => [ 314 | 'url' => 'http://example.com:0/path:0/path', 315 | 'expectedComponents' => [ 316 | Parser::COMPONENT_SCHEME => 'http', 317 | Parser::COMPONENT_HOST => 'example.com', 318 | Parser::COMPONENT_PORT => '0', 319 | Parser::COMPONENT_PATH => '/path:0/path', 320 | ], 321 | ], 322 | 'invalid port (too small), with query containing port-like pattern' => [ 323 | 'url' => 'http://example.com:0?:0', 324 | 'expectedComponents' => [ 325 | Parser::COMPONENT_SCHEME => 'http', 326 | Parser::COMPONENT_HOST => 'example.com', 327 | Parser::COMPONENT_PORT => '0', 328 | Parser::COMPONENT_QUERY => ':0', 329 | ], 330 | ], 331 | 'invalid port (too small), with fragment containing port-like pattern' => [ 332 | 'url' => 'http://example.com:0#:0', 333 | 'expectedComponents' => [ 334 | Parser::COMPONENT_SCHEME => 'http', 335 | Parser::COMPONENT_HOST => 'example.com', 336 | Parser::COMPONENT_PORT => '0', 337 | Parser::COMPONENT_FRAGMENT => ':0', 338 | ], 339 | ], 340 | 'invalid port (too small), with fragment containing port-like pattern and query-like pattern' => [ 341 | 'url' => 'http://example.com:0#:0?:0', 342 | 'expectedComponents' => [ 343 | Parser::COMPONENT_SCHEME => 'http', 344 | Parser::COMPONENT_HOST => 'example.com', 345 | Parser::COMPONENT_PORT => '0', 346 | Parser::COMPONENT_FRAGMENT => ':0?:0', 347 | ], 348 | ], 349 | ]; 350 | } 351 | } 352 | -------------------------------------------------------------------------------- /tests/PathTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($expectedPath, (string)$path); 20 | } 21 | 22 | public function createDataProvider(): array 23 | { 24 | return [ 25 | 'empty' => [ 26 | 'pathString' => '', 27 | 'expectedPath' => '', 28 | ], 29 | 'non-empty' => [ 30 | 'pathString' => '/foo', 31 | 'expectedPath' => '/foo', 32 | ], 33 | 'percent-encode unicode characters' => [ 34 | 'path' => '/Nattō', 35 | 'expectedPath' => '/Natt%C5%8D', 36 | ], 37 | ]; 38 | } 39 | 40 | /** 41 | * @dataProvider isRelativeDataProvider 42 | * 43 | * @param string $pathString 44 | * @param bool $expectedIsRelative 45 | */ 46 | public function testIsRelative(string $pathString, bool $expectedIsRelative) 47 | { 48 | $path = new Path($pathString); 49 | 50 | $this->assertEquals($expectedIsRelative, $path->isRelative()); 51 | } 52 | 53 | public function isRelativeDataProvider(): array 54 | { 55 | return [ 56 | 'empty path is relative' => [ 57 | 'pathString' => '', 58 | 'expectedIsRelative' => true, 59 | ], 60 | 'foo is relative' => [ 61 | 'pathString' => 'foo', 62 | 'expectedIsRelative' => true, 63 | ], 64 | '/foo is not relative' => [ 65 | 'pathString' => '/foo', 66 | 'expectedIsRelative' => false, 67 | ], 68 | ]; 69 | } 70 | 71 | /** 72 | * @dataProvider isAbsoluteDataProvider 73 | * 74 | * @param string $pathString 75 | * @param bool $expectedIsAbsolute 76 | */ 77 | public function testIsAbsolute(string $pathString, bool $expectedIsAbsolute) 78 | { 79 | $path = new Path($pathString); 80 | 81 | $this->assertEquals($expectedIsAbsolute, $path->isAbsolute()); 82 | } 83 | 84 | public function isAbsoluteDataProvider(): array 85 | { 86 | return [ 87 | 'empty path is not absolute' => [ 88 | 'pathString' => '', 89 | 'expectedIsAbsolute' => false, 90 | ], 91 | 'foo is not absolute' => [ 92 | 'pathString' => 'foo', 93 | 'expectedIsAbsolute' => false, 94 | ], 95 | '/foo is absolute' => [ 96 | 'pathString' => '/foo', 97 | 'expectedIsAbsolute' => true, 98 | ], 99 | ]; 100 | } 101 | 102 | /** 103 | * @dataProvider filenameAndDirectoryPropertiesDataProvider 104 | * 105 | * @param string $pathString 106 | * @param bool $expectedHasFilename 107 | * @param string $expectedFilename 108 | * @param string $expectedDirectory 109 | * @param bool $expectedHasTrailingSlash 110 | */ 111 | public function testFilenameAndDirectoryProperties( 112 | string $pathString, 113 | bool $expectedHasFilename, 114 | string $expectedFilename, 115 | string $expectedDirectory, 116 | bool $expectedHasTrailingSlash 117 | ) { 118 | $path = new Path($pathString); 119 | 120 | $this->assertEquals($expectedHasFilename, $path->hasFilename()); 121 | $this->assertEquals($expectedFilename, $path->getFilename()); 122 | $this->assertEquals($expectedDirectory, $path->getDirectory()); 123 | $this->assertEquals($expectedHasTrailingSlash, $path->hasTrailingSlash()); 124 | } 125 | 126 | public function filenameAndDirectoryPropertiesDataProvider(): array 127 | { 128 | return [ 129 | '/example/' => [ 130 | 'pathString' => '/example/', 131 | 'expectedHasFilename' => false, 132 | 'expectedFilename' => '', 133 | 'expectedDirectory' => '/example/', 134 | 'expectedHasTrailingSlash' => true, 135 | ], 136 | '/file.txt' => [ 137 | 'pathString' => '/file.txt', 138 | 'expectedHasFilename' => true, 139 | 'expectedFilename' => 'file.txt', 140 | 'expectedDirectory' => '/', 141 | 'expectedHasTrailingSlash' => false, 142 | ], 143 | '/example/file.txt' => [ 144 | 'pathString' => '/example/file.txt', 145 | 'expectedHasFilename' => true, 146 | 'expectedFilename' => 'file.txt', 147 | 'expectedDirectory' => '/example', 148 | 'expectedHasTrailingSlash' => false, 149 | ], 150 | '/example/file.txt/e' => [ 151 | 'pathString' => '/example/file.txt/', 152 | 'expectedHasFilename' => false, 153 | 'expectedFilename' => '', 154 | 'expectedDirectory' => '/example/file.txt/', 155 | 'expectedHasTrailingSlash' => true, 156 | ], 157 | ]; 158 | } 159 | 160 | /** 161 | * @dataProvider hasTrailingSlashDataProvider 162 | * 163 | * @param string $pathString 164 | * @param bool $expectedHasTrailingSlash 165 | */ 166 | public function testHasTrailingSlash(string $pathString, bool $expectedHasTrailingSlash) 167 | { 168 | $path = new Path($pathString); 169 | 170 | $this->assertSame($expectedHasTrailingSlash, $path->hasTrailingSlash()); 171 | } 172 | 173 | public function hasTrailingSlashDataProvider(): array 174 | { 175 | return [ 176 | 'empty path does not have trailing slash' => [ 177 | 'pathString' => '', 178 | 'expectedHasTrailingSlash' => false, 179 | ], 180 | 'does not have trailing slash' => [ 181 | 'pathString' => '/path', 182 | 'expectedHasTrailingSlash' => false, 183 | ], 184 | 'has trailing slash' => [ 185 | 'pathString' => '/path/', 186 | 'expectedHasTrailingSlash' => true, 187 | ], 188 | ]; 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /tests/PunycodeEncoderTest.php: -------------------------------------------------------------------------------- 1 | assertSame($expectedValue, PunycodeEncoder::encode($value)); 18 | } 19 | 20 | public function encodeDataProvider(): array 21 | { 22 | return [ 23 | 'ascii' => [ 24 | 'value' => 'foo', 25 | 'expectedValue' => 'foo', 26 | ], 27 | 'unicode' => [ 28 | 'value' => '♥', 29 | 'expectedValue' => 'xn--g6h', 30 | ], 31 | 'punycode' => [ 32 | 'value' => 'xn--g6h', 33 | 'expectedValue' => 'xn--g6h', 34 | ], 35 | ]; 36 | } 37 | 38 | /** 39 | * @dataProvider decodeDataProvider 40 | * 41 | * @param string $value 42 | * @param string $expectedValue 43 | */ 44 | public function testDecode(string $value, string $expectedValue) 45 | { 46 | $this->assertSame($expectedValue, PunycodeEncoder::decode($value)); 47 | } 48 | 49 | public function decodeDataProvider(): array 50 | { 51 | return [ 52 | 'ascii' => [ 53 | 'value' => 'foo', 54 | 'expectedValue' => 'foo', 55 | ], 56 | 'unicode' => [ 57 | 'value' => '♥', 58 | 'expectedValue' => '♥', 59 | ], 60 | 'punycode' => [ 61 | 'value' => 'xn--g6h', 62 | 'expectedValue' => '♥', 63 | ], 64 | ]; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/ScopeComparerTest.php: -------------------------------------------------------------------------------- 1 | addEquivalentSchemes($equivalentSchemeSet); 32 | } 33 | } 34 | 35 | if (!empty($equivalentHostSets)) { 36 | foreach ($equivalentHostSets as $equivalentHostSet) { 37 | $scopeComparer->addEquivalentHosts($equivalentHostSet); 38 | } 39 | } 40 | 41 | $this->assertEquals($expectedIsInScope, $scopeComparer->isInScope($sourceUrl, $comparatorUrl)); 42 | } 43 | 44 | public function isInScopeDataProvider(): array 45 | { 46 | return [ 47 | 'two empty urls are in scope' => [ 48 | 'sourceUrl' => new Url(''), 49 | 'comparatorUrl' => new Url(''), 50 | 'equivalentSchemeSets' => [], 51 | 'equivalentHostSets' => [], 52 | 'expectedIsInScope' => true, 53 | ], 54 | 'different schemes, no equivalent schemes, not in scope' => [ 55 | 'sourceUrl' => new Url('http://example.com/'), 56 | 'comparatorUrl' => new Url('https://example.com/'), 57 | 'equivalentSchemeSets' => [], 58 | 'equivalentHostSets' => [], 59 | 'expectedIsInScope' => false, 60 | ], 61 | 'different schemes, has equivalent schemes, is in scope' => [ 62 | 'sourceUrl' => new Url('http://example.com/'), 63 | 'comparatorUrl' => new Url('https://example.com/'), 64 | 'equivalentSchemeSets' => [ 65 | [ 66 | 'http', 67 | 'https', 68 | ], 69 | ], 70 | 'equivalentHostSets' => [], 71 | 'expectedIsInScope' => true, 72 | ], 73 | 'comparator as substring of source, is not in scope' => [ 74 | 'sourceUrl' => new Url('http://example.com/foo'), 75 | 'comparatorUrl' => new Url('http://example.com/'), 76 | 'equivalentSchemeSets' => [], 77 | 'equivalentHostSets' => [], 78 | 'expectedIsInScope' => false, 79 | ], 80 | 'source as substring of comparator, is in scope' => [ 81 | 'sourceUrl' => new Url('http://example.com/'), 82 | 'comparatorUrl' => new Url('http://example.com/foo'), 83 | 'equivalentSchemeSets' => [], 84 | 'equivalentHostSets' => [], 85 | 'expectedIsInScope' => true, 86 | ], 87 | 'different hosts, no equivalent hosts, not in scope' => [ 88 | 'sourceUrl' => new Url('http://example.com/'), 89 | 'comparatorUrl' => new Url('https://example.com/'), 90 | 'equivalentSchemeSets' => [], 91 | 'equivalentHostSets' => [], 92 | 'expectedIsInScope' => false, 93 | ], 94 | 'different hosts, has equivalent hosts, is in scope' => [ 95 | 'sourceUrl' => new Url('http://www.example.com/'), 96 | 'comparatorUrl' => new Url('http://example.com/'), 97 | 'equivalentSchemeSets' => [], 98 | 'equivalentHostSets' => [ 99 | [ 100 | 'www.example.com', 101 | 'example.com', 102 | ], 103 | ], 104 | 'expectedIsInScope' => true, 105 | ], 106 | 'equivalent schemes, equivalent hosts, identical path, is in scope' => [ 107 | 'sourceUrl' => new Url('https://www.example.com/'), 108 | 'comparatorUrl' => new Url('http://example.com/'), 109 | 'equivalentSchemeSets' => [ 110 | [ 111 | 'http', 112 | 'https', 113 | ], 114 | ], 115 | 'equivalentHostSets' => [ 116 | [ 117 | 'www.example.com', 118 | 'example.com', 119 | ], 120 | ], 121 | 'expectedIsInScope' => true, 122 | ], 123 | 'equivalent schemes, non-equivalent hosts, identical path, not in scope' => [ 124 | 'sourceUrl' => new Url('https://www.example.com/'), 125 | 'comparatorUrl' => new Url('http://example.com/'), 126 | 'equivalentSchemeSets' => [ 127 | [ 128 | 'http', 129 | 'https', 130 | ], 131 | ], 132 | 'equivalentHostSets' => [], 133 | 'expectedIsInScope' => false, 134 | ], 135 | 'equivalent schemes, equivalent hosts, source has no path, is in scope' => [ 136 | 'sourceUrl' => new Url('https://www.example.com'), 137 | 'comparatorUrl' => new Url('http://example.com/foo'), 138 | 'equivalentSchemeSets' => [ 139 | [ 140 | 'http', 141 | 'https', 142 | ], 143 | ], 144 | 'equivalentHostSets' => [ 145 | [ 146 | 'www.example.com', 147 | 'example.com', 148 | ], 149 | ], 150 | 'expectedIsInScope' => true, 151 | ], 152 | 'equivalent schemes, equivalent hosts, source path substring of comparator path, is in scope' => [ 153 | 'sourceUrl' => new Url('https://www.example.com/foo'), 154 | 'comparatorUrl' => new Url('http://example.com/foo/bar'), 155 | 'equivalentSchemeSets' => [ 156 | [ 157 | 'http', 158 | 'https', 159 | ], 160 | ], 161 | 'equivalentHostSets' => [ 162 | [ 163 | 'www.example.com', 164 | 'example.com', 165 | ], 166 | ], 167 | 'expectedIsInScope' => true, 168 | ], 169 | 'different ports; port difference is ignored' => [ 170 | 'sourceUrl' => new Url('http://example.com/'), 171 | 'comparatorUrl' => new Url('http://example.com:8080/'), 172 | 'equivalentSchemeSets' => [], 173 | 'equivalentHostSets' => [], 174 | 'expectedIsInScope' => true, 175 | ], 176 | 'different users; user difference is ignored' => [ 177 | 'sourceUrl' => new Url('http://foo:password@example.com/'), 178 | 'comparatorUrl' => new Url('http://bar:password@example.com/'), 179 | 'equivalentSchemeSets' => [], 180 | 'equivalentHostSets' => [], 181 | 'expectedIsInScope' => true, 182 | ], 183 | 'different passwords; password difference is ignored' => [ 184 | 'sourceUrl' => new Url('http://user:foo@example.com/'), 185 | 'comparatorUrl' => new Url('http://user:bar@example.com/'), 186 | 'equivalentSchemeSets' => [], 187 | 'equivalentHostSets' => [], 188 | 'expectedIsInScope' => true, 189 | ], 190 | 'different queries; query difference is ignored' => [ 191 | 'sourceUrl' => new Url('http://example.com/?foo=bar'), 192 | 'comparatorUrl' => new Url('http://example.com/?bar=foo'), 193 | 'equivalentSchemeSets' => [], 194 | 'equivalentHostSets' => [], 195 | 'expectedIsInScope' => true, 196 | ], 197 | 'different fragments; fragment difference is ignored' => [ 198 | 'sourceUrl' => new Url('http://example.com/#foo'), 199 | 'comparatorUrl' => new Url('http://example.com/#bar'), 200 | 'equivalentSchemeSets' => [], 201 | 'equivalentHostSets' => [], 202 | 'expectedIsInScope' => true, 203 | ], 204 | ]; 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /tests/UrlTest.php: -------------------------------------------------------------------------------- 1 | expectException(\InvalidArgumentException::class); 17 | 18 | new Url('http://example.com:' . (Filter::MIN_PORT - 1)); 19 | } 20 | 21 | /** 22 | * @dataProvider getSchemeDataProvider 23 | * 24 | * @param string $scheme 25 | * @param string $expectedScheme 26 | */ 27 | public function testGetScheme(string $scheme, string $expectedScheme) 28 | { 29 | $url = new Url(''); 30 | $url = $url->withScheme($scheme); 31 | 32 | $this->assertEquals($expectedScheme, $url->getScheme()); 33 | } 34 | 35 | public function getSchemeDataProvider(): array 36 | { 37 | return [ 38 | 'http lowercase' => [ 39 | 'scheme' => 'http', 40 | 'expectedScheme' => 'http', 41 | ], 42 | 'http uppercase' => [ 43 | 'scheme' => 'HTTP', 44 | 'expectedScheme' => 'http', 45 | ], 46 | 'https lowercase' => [ 47 | 'scheme' => 'https', 48 | 'expectedScheme' => 'https', 49 | ], 50 | 'https uppercase' => [ 51 | 'scheme' => 'HTTPS', 52 | 'expectedScheme' => 'https', 53 | ], 54 | ]; 55 | } 56 | 57 | /** 58 | * @dataProvider getAuthorityDataProvider 59 | * 60 | * @param string $url 61 | * @param string $expectedAuthority 62 | */ 63 | public function testGetAuthority(string $url, string $expectedAuthority) 64 | { 65 | $this->assertSame($expectedAuthority, (new Url($url))->getAuthority()); 66 | } 67 | 68 | public function getAuthorityDataProvider(): array 69 | { 70 | return [ 71 | 'scheme, host' => [ 72 | 'uri' => 'http://example.com', 73 | 'expectedAuthority' => 'example.com', 74 | ], 75 | 'scheme, host, user' => [ 76 | 'uri' => 'http://user@example.com', 77 | 'expectedAuthority' => 'user@example.com', 78 | ], 79 | 'scheme, host, password' => [ 80 | 'uri' => 'http://:password@example.com', 81 | 'expectedAuthority' => ':password@example.com', 82 | ], 83 | 'scheme, host, user, password' => [ 84 | 'uri' => 'http://user:password@example.com', 85 | 'expectedAuthority' => 'user:password@example.com', 86 | ], 87 | 'scheme, host, user, password, default port (http' => [ 88 | 'uri' => 'http://user:password@example.com:80', 89 | 'expectedAuthority' => 'user:password@example.com', 90 | ], 91 | 'scheme, host, user, password, default port (https' => [ 92 | 'uri' => 'https://user:password@example.com:443', 93 | 'expectedAuthority' => 'user:password@example.com', 94 | ], 95 | 'scheme, host, user, password, non-default port (http' => [ 96 | 'uri' => 'http://user:password@example.com:8080', 97 | 'expectedAuthority' => 'user:password@example.com:8080', 98 | ], 99 | 'scheme, host, user, password, non-default port (https' => [ 100 | 'uri' => 'https://user:password@example.com:4433', 101 | 'expectedAuthority' => 'user:password@example.com:4433', 102 | ], 103 | ]; 104 | } 105 | 106 | /** 107 | * @dataProvider getUserInfoDataProvider 108 | * 109 | * @param string $url 110 | * @param string $expectedUserInfo 111 | */ 112 | public function testGetUserInfo(string $url, string $expectedUserInfo) 113 | { 114 | $this->assertSame($expectedUserInfo, (new Url($url))->getUserInfo()); 115 | } 116 | 117 | public function getUserInfoDataProvider(): array 118 | { 119 | return [ 120 | 'scheme, host' => [ 121 | 'uri' => 'http://example.com', 122 | 'expectedUserInfo' => '', 123 | ], 124 | 'scheme, host, user' => [ 125 | 'uri' => 'http://user@example.com', 126 | 'expectedUserInfo' => 'user', 127 | ], 128 | 'scheme, host, password' => [ 129 | 'uri' => 'http://:password@example.com', 130 | 'expectedUserInfo' => ':password', 131 | ], 132 | 'scheme, host, user, password' => [ 133 | 'uri' => 'http://user:password@example.com', 134 | 'expectedUserInfo' => 'user:password', 135 | ], 136 | 'host' => [ 137 | 'uri' => 'example.com', 138 | 'expectedUserInfo' => '', 139 | ], 140 | 'host, user (without scheme is indistinguishable from being the path' => [ 141 | 'uri' => 'user@example.com', 142 | 'expectedUserInfo' => '', 143 | ], 144 | 'host, password (without scheme is indistinguishable from being the path' => [ 145 | 'uri' => 'password@example.com', 146 | 'expectedUserInfo' => '', 147 | ], 148 | 'host, user, password (without scheme is indistinguishable from being the path' => [ 149 | 'uri' => 'user:password@example.com', 150 | 'expectedUserInfo' => '', 151 | ], 152 | ]; 153 | } 154 | 155 | /** 156 | * @dataProvider getHostDataProvider 157 | * 158 | * @param string $url 159 | * @param string $expectedHost 160 | */ 161 | public function testGetHost(string $url, string $expectedHost) 162 | { 163 | $this->assertSame($expectedHost, (new Url($url))->getHost()); 164 | } 165 | 166 | public function getHostDataProvider(): array 167 | { 168 | return [ 169 | 'scheme, host' => [ 170 | 'uri' => 'http://example.com', 171 | 'expectedHost' => 'example.com', 172 | ], 173 | 'scheme, host, port' => [ 174 | 'uri' => 'http://example.com:8080', 175 | 'expectedHost' => 'example.com', 176 | ], 177 | 'scheme, host, userinfo' => [ 178 | 'uri' => 'http://user:password@example.com', 179 | 'expectedHost' => 'example.com', 180 | ], 181 | 'scheme, host, path' => [ 182 | 'uri' => 'http://@example.com/path', 183 | 'expectedHost' => 'example.com', 184 | ], 185 | 'scheme, host, path, fragment' => [ 186 | 'uri' => 'http://@example.com/path#fragment', 187 | 'expectedHost' => 'example.com', 188 | ], 189 | ]; 190 | } 191 | 192 | /** 193 | * @dataProvider getPortDataProvider 194 | * 195 | * @param string $url 196 | * @param int|null $expectedPort 197 | */ 198 | public function testGetPort(string $url, ?int $expectedPort) 199 | { 200 | $this->assertSame($expectedPort, (new Url($url))->getPort()); 201 | } 202 | 203 | public function getPortDataProvider(): array 204 | { 205 | return [ 206 | 'no port' => [ 207 | 'uri' => 'http://example.com', 208 | 'expectedPort' => null, 209 | ], 210 | 'http default port' => [ 211 | 'uri' => 'http://example.com:80', 212 | 'expectedPort' => null, 213 | ], 214 | 'https default port' => [ 215 | 'uri' => 'https://example.com:443', 216 | 'expectedPort' => null, 217 | ], 218 | 'http non-default port' => [ 219 | 'uri' => 'http://example.com:8080', 220 | 'expectedPort' => 8080, 221 | ], 222 | 'https non-default port' => [ 223 | 'uri' => 'https://example.com:4433', 224 | 'expectedPort' => 4433, 225 | ], 226 | ]; 227 | } 228 | 229 | /** 230 | * @dataProvider getPathGetQueryGetFragmentDataProvider 231 | * 232 | * @param string $url 233 | * @param string $expectedPath 234 | * @param string $expectedQuery 235 | * @param string $expectedFragment 236 | */ 237 | public function testGetPathGetQueryGetFragment( 238 | string $url, 239 | string $expectedPath, 240 | string $expectedQuery, 241 | string $expectedFragment 242 | ) { 243 | $uriObject = new Url($url); 244 | 245 | $this->assertSame($expectedPath, $uriObject->getPath()); 246 | $this->assertSame($expectedQuery, $uriObject->getQuery()); 247 | $this->assertSame($expectedFragment, $uriObject->getFragment()); 248 | } 249 | 250 | public function getPathGetQueryGetFragmentDataProvider(): array 251 | { 252 | return [ 253 | 'empty' => [ 254 | 'uri' => '', 255 | 'expectedPath' => '', 256 | 'expectedQuery' => '', 257 | 'expectedFragment' => '', 258 | ], 259 | 'relative path' => [ 260 | 'uri' => 'path', 261 | 'expectedPath' => 'path', 262 | 'expectedQuery' => '', 263 | 'expectedFragment' => '', 264 | ], 265 | 'absolute path' => [ 266 | 'uri' => '/path', 267 | 'expectedPath' => '/path', 268 | 'expectedQuery' => '', 269 | 'expectedFragment' => '', 270 | ], 271 | 'query' => [ 272 | 'uri' => '?query', 273 | 'expectedPath' => '', 274 | 'expectedQuery' => 'query', 275 | 'expectedFragment' => '', 276 | ], 277 | 'fragment' => [ 278 | 'uri' => '#fragment', 279 | 'expectedPath' => '', 280 | 'expectedQuery' => '', 281 | 'expectedFragment' => 'fragment', 282 | ], 283 | 'full url' => [ 284 | 'uri' => 'http://example.com/path?query#fragment', 285 | 'expectedPath' => '/path', 286 | 'expectedQuery' => 'query', 287 | 'expectedFragment' => 'fragment', 288 | ], 289 | ]; 290 | } 291 | 292 | public function testWithScheme() 293 | { 294 | $httpUrl = new Url('http://example.com'); 295 | $this->assertSame('http', $httpUrl->getScheme()); 296 | 297 | $httpsUrl = $httpUrl->withScheme('https'); 298 | $this->assertSame('https', $httpsUrl->getScheme()); 299 | $this->assertNotSame($httpUrl, $httpsUrl); 300 | $this->assertSame('https://example.com', (string) $httpsUrl); 301 | } 302 | 303 | public function testWithSchemeRemovesDefaultPort() 304 | { 305 | $httpUrl = new Url('http://example.com:443'); 306 | $this->assertSame(443, $httpUrl->getPort()); 307 | 308 | $httpsUrl = $httpUrl->withScheme('https'); 309 | $this->assertNull($httpsUrl->getPort()); 310 | } 311 | 312 | public function testWithUserInfo() 313 | { 314 | $uriWithoutUserInfo = new Url('http://example.com'); 315 | $this->assertSame('', $uriWithoutUserInfo->getUserInfo()); 316 | 317 | $uriWithUserOnly = $uriWithoutUserInfo->withUserInfo('user'); 318 | $this->assertSame('user', $uriWithUserOnly->getUserInfo()); 319 | $this->assertNotSame($uriWithoutUserInfo, $uriWithUserOnly); 320 | $this->assertSame( 321 | 'http://user@example.com', 322 | (string) $uriWithUserOnly 323 | ); 324 | 325 | $uriWithUserAndPassword = $uriWithUserOnly->withUserInfo('user-with-password', 'password'); 326 | $this->assertNotSame($uriWithUserOnly, $uriWithUserAndPassword); 327 | $this->assertSame('user-with-password:password', $uriWithUserAndPassword->getUserInfo()); 328 | 329 | $uriWithSameUserAndPassword = $uriWithUserAndPassword->withUserInfo('user-with-password', 'password'); 330 | $this->assertSame($uriWithUserAndPassword, $uriWithSameUserAndPassword); 331 | 332 | $uriWithUserInfoRemoved = $uriWithUserAndPassword->withUserInfo(''); 333 | $this->assertSame('', $uriWithUserInfoRemoved->getUserInfo()); 334 | } 335 | 336 | public function testWithHost() 337 | { 338 | $uriWithOnlyPath = new Url('/path'); 339 | $this->assertSame('', $uriWithOnlyPath->getHost()); 340 | 341 | $uriWithPathAndHost = $uriWithOnlyPath->withHost('example.com'); 342 | $this->assertSame('example.com', $uriWithPathAndHost->getHost()); 343 | $this->assertNotSame($uriWithOnlyPath, $uriWithPathAndHost); 344 | $this->assertSame('//example.com/path', (string) $uriWithPathAndHost); 345 | 346 | $uriWithSamePathAndHost = $uriWithPathAndHost->withHost('example.com'); 347 | $this->assertSame($uriWithPathAndHost, $uriWithSamePathAndHost); 348 | 349 | $uriWithChangedHost = $uriWithSamePathAndHost->withHost('foo.example.com'); 350 | $this->assertSame('foo.example.com', $uriWithChangedHost->getHost()); 351 | 352 | $uriWithRemovedHost = $uriWithPathAndHost->withHost(''); 353 | $this->assertSame('', $uriWithRemovedHost->getHost()); 354 | } 355 | 356 | public function testWithPortInvalidPort() 357 | { 358 | $url = new Url('http://example.com/'); 359 | 360 | $this->expectException(\InvalidArgumentException::class); 361 | 362 | $url->withPort(Filter::MIN_PORT - 1); 363 | } 364 | 365 | public function testWithPort() 366 | { 367 | $httpUriWithoutPort = new Url('http://example.com'); 368 | $this->assertNull($httpUriWithoutPort->getPort()); 369 | 370 | $httpUriWithDefaultPortAdded = $httpUriWithoutPort->withPort(80); 371 | $this->assertNull($httpUriWithDefaultPortAdded->getPort()); 372 | $this->assertNotSame($httpUriWithoutPort, $httpUriWithDefaultPortAdded); 373 | $this->assertSame('http://example.com', (string) $httpUriWithDefaultPortAdded); 374 | 375 | $httpUriWithNonDefaultPort = $httpUriWithDefaultPortAdded->withPort(8080); 376 | $this->assertSame(8080, $httpUriWithNonDefaultPort->getPort()); 377 | 378 | $httpUriWithSameNonDefaultPort = $httpUriWithNonDefaultPort->withPort(8080); 379 | $this->assertSame($httpUriWithNonDefaultPort, $httpUriWithSameNonDefaultPort); 380 | 381 | $httpUriWithPortRemoved = $httpUriWithNonDefaultPort->withPort(null); 382 | $this->assertNull($httpUriWithPortRemoved->getPort()); 383 | } 384 | 385 | public function testWithPath() 386 | { 387 | $uriWithoutPath = new Url('http://example.com'); 388 | $this->assertSame('', $uriWithoutPath->getPath()); 389 | 390 | $uriWithPathAdded = $uriWithoutPath->withPath('/path'); 391 | $this->assertSame('/path', $uriWithPathAdded->getPath()); 392 | $this->assertNotSame($uriWithoutPath, $uriWithPathAdded); 393 | $this->assertSame('http://example.com/path', (string) $uriWithPathAdded); 394 | 395 | $uriWithSamePathAdded = $uriWithPathAdded->withPath('/path'); 396 | $this->assertSame($uriWithPathAdded, $uriWithSamePathAdded); 397 | 398 | $uriWithPathRemoved = $uriWithSamePathAdded->withPath(''); 399 | $this->assertSame('', $uriWithPathRemoved->getPath()); 400 | } 401 | 402 | public function testWithQuery() 403 | { 404 | $uriWithoutQuery = new Url('http://example.com'); 405 | $this->assertSame('', $uriWithoutQuery->getQuery()); 406 | 407 | $uriWithQueryAdded = $uriWithoutQuery->withQuery('foo=bar'); 408 | $this->assertSame('foo=bar', $uriWithQueryAdded->getQuery()); 409 | $this->assertNotSame($uriWithoutQuery, $uriWithQueryAdded); 410 | $this->assertSame('http://example.com?foo=bar', (string) $uriWithQueryAdded); 411 | 412 | $uriWithSameQueryAdded = $uriWithQueryAdded->withQuery('foo=bar'); 413 | $this->assertSame($uriWithQueryAdded, $uriWithSameQueryAdded); 414 | 415 | $uriWithQueryRemoved = $uriWithSameQueryAdded->withQuery(''); 416 | $this->assertSame('', $uriWithQueryRemoved->getQuery()); 417 | } 418 | 419 | public function testWithFragment() 420 | { 421 | $uriWithoutFragment = new Url('http://example.com'); 422 | $this->assertSame('', $uriWithoutFragment->getFragment()); 423 | 424 | $uriWithFragmentAdded = $uriWithoutFragment->withFragment('fragment'); 425 | $this->assertSame('fragment', $uriWithFragmentAdded->getFragment()); 426 | $this->assertNotSame($uriWithoutFragment, $uriWithFragmentAdded); 427 | $this->assertSame('http://example.com#fragment', (string) $uriWithFragmentAdded); 428 | 429 | $uriWithFragmentRemoved = $uriWithFragmentAdded->withFragment(''); 430 | $this->assertSame('', $uriWithFragmentRemoved->getFragment()); 431 | } 432 | 433 | /** 434 | * @dataProvider toStringWithMutationDataProvider 435 | * 436 | * @param Url $url 437 | * @param string $expectedUri 438 | */ 439 | public function testToStringWithMutation(Url $url, string $expectedUri) 440 | { 441 | $this->assertSame($expectedUri, (string) $url); 442 | } 443 | 444 | public function toStringWithMutationDataProvider(): array 445 | { 446 | return [ 447 | 'fragment only' => [ 448 | 'uri' => (new Url(''))->withFragment('fragment'), 449 | 'expectedUrl' => '#fragment', 450 | ], 451 | 'query only' => [ 452 | 'uri' => (new Url(''))->withQuery('query'), 453 | 'expectedUrl' => '?query', 454 | ], 455 | 'path only' => [ 456 | 'uri' => (new Url(''))->withPath('/path'), 457 | 'expectedUrl' => '/path', 458 | ], 459 | 'path only, starts with //' => [ 460 | 'uri' => (new Url(''))->withPath('//path'), 461 | 'expectedUrl' => '/path', 462 | ], 463 | 'path and host, path does not start with /' => [ 464 | 'uri' => (new Url(''))->withHost('example.com')->withPath('path'), 465 | 'expectedUrl' => '//example.com/path', 466 | ], 467 | ]; 468 | } 469 | 470 | /** 471 | * @dataProvider toStringDataProvider 472 | * 473 | * @param string $url 474 | */ 475 | public function testToString(string $url) 476 | { 477 | $this->assertSame($url, (string) new Url($url)); 478 | } 479 | 480 | public function toStringDataProvider(): array 481 | { 482 | return [ 483 | 'scheme' => [ 484 | 'uri' => 'file://', 485 | ], 486 | 'scheme, host' => [ 487 | 'uri' => 'http://example.com', 488 | ], 489 | 'scheme, user, host' => [ 490 | 'uri' => 'http://user@example.com', 491 | ], 492 | 'scheme, password, host' => [ 493 | 'uri' => 'http://:password@example.com', 494 | ], 495 | 'scheme, user, password, host' => [ 496 | 'uri' => 'http://user:password@example.com', 497 | ], 498 | 'scheme, user, password, host, port' => [ 499 | 'uri' => 'http://user:password@example.com:8080', 500 | ], 501 | 'scheme, user, password, host, port, path' => [ 502 | 'uri' => 'http://user:password@example.com:8080/path', 503 | ], 504 | 'scheme, user, password, host, port, path, query' => [ 505 | 'uri' => 'http://user:password@example.com:8080/path?query', 506 | ], 507 | 'scheme, user, password, host, port, path, query, fragment' => [ 508 | 'uri' => 'http://user:password@example.com:8080/path?query#fragment', 509 | ], 510 | 'scheme, user, password, unicode host, port, path, query, fragment' => [ 511 | 'uri' => 'http://user:password@♥.example.com:8080/path?query#fragment', 512 | ], 513 | ]; 514 | } 515 | 516 | /** 517 | * @dataProvider encodingOfGenAndSubDelimitersDataProvider 518 | * 519 | * @param string $url 520 | * @param string $expectedPath 521 | * @param string $expectedQuery 522 | * @param string $expectedFragment 523 | */ 524 | public function testEncodingOfGenAndSubDelimiters( 525 | string $url, 526 | string $expectedPath, 527 | string $expectedQuery, 528 | string $expectedFragment 529 | ) { 530 | $uriObject = new Url($url); 531 | 532 | $this->assertSame($expectedPath, $uriObject->getPath()); 533 | $this->assertSame($expectedQuery, $uriObject->getQuery()); 534 | $this->assertSame($expectedFragment, $uriObject->getFragment()); 535 | $this->assertSame($url, (string) $url); 536 | } 537 | 538 | public function encodingOfGenAndSubDelimitersDataProvider(): array 539 | { 540 | return [ 541 | 'no path, no query, no fragment' => [ 542 | 'uri' => 'http://example.com', 543 | 'expectedPath' => '', 544 | 'expectedQuery' => '', 545 | 'expectedFragment' => '', 546 | ], 547 | 'sub-delimiters in path' => [ 548 | 'uri' => 'http://example.com/' . self::SUB_DELIMITERS, 549 | 'expectedPath' => '/' . self::SUB_DELIMITERS, 550 | 'expectedQuery' => '', 551 | 'expectedFragment' => '', 552 | ], 553 | 'sub-delimiters in query' => [ 554 | 'uri' => 'http://example.com?' . self::SUB_DELIMITERS, 555 | 'expectedPath' => '', 556 | 'expectedQuery' => self::SUB_DELIMITERS, 557 | 'expectedFragment' => '', 558 | ], 559 | 'sub-delimiters in fragment' => [ 560 | 'uri' => 'http://example.com#' . self::SUB_DELIMITERS, 561 | 'expectedPath' => '', 562 | 'expectedQuery' => '', 563 | 'expectedFragment' => self::SUB_DELIMITERS, 564 | ], 565 | 'sub-delimiters in path, query, fragment' => [ 566 | 'uri' => sprintf( 567 | 'http://example.com/%s?%s#%s', 568 | self::SUB_DELIMITERS, 569 | self::SUB_DELIMITERS, 570 | self::SUB_DELIMITERS 571 | ), 572 | 'expectedPath' => '/' . self::SUB_DELIMITERS, 573 | 'expectedQuery' => self::SUB_DELIMITERS, 574 | 'expectedFragment' => self::SUB_DELIMITERS, 575 | ], 576 | 'gen-delimiters in fragment' => [ 577 | 'uri' => 'http://example.com?#' . self::GEN_DELIMITERS, 578 | 'expectedPath' => '', 579 | 'expectedQuery' => '', 580 | 'expectedFragment' => ':/?%23%5B%5D@', 581 | ], 582 | ]; 583 | } 584 | } 585 | -------------------------------------------------------------------------------- /tests/UserInfoTest.php: -------------------------------------------------------------------------------- 1 | assertSame($expectedString, (string) $userInfo); 21 | } 22 | 23 | public function toStringDataProvider(): array 24 | { 25 | return [ 26 | 'user empty, password null' => [ 27 | 'user' => '', 28 | 'password' => null, 29 | 'expectedString' => '', 30 | ], 31 | 'user empty, password empty' => [ 32 | 'user' => '', 33 | 'password' => '', 34 | 'expectedString' => '', 35 | ], 36 | 'user only' => [ 37 | 'user' => 'user', 38 | 'password' => null, 39 | 'expectedString' => 'user', 40 | ], 41 | 'user and password' => [ 42 | 'user' => 'user', 43 | 'password' => 'password', 44 | 'expectedString' => 'user:password', 45 | ], 46 | ]; 47 | } 48 | 49 | /** 50 | * @dataProvider fromStringDataProvider 51 | * 52 | * @param string $userInfoString 53 | * @param string $expectedUser 54 | * @param string|null $expectedPassword 55 | * 56 | */ 57 | public function testFromString(string $userInfoString, string $expectedUser, ?string $expectedPassword) 58 | { 59 | $userInfo = UserInfo::fromString($userInfoString); 60 | 61 | $this->assertSame($expectedUser, $userInfo->getUser()); 62 | $this->assertSame($expectedPassword, $userInfo->getPassword()); 63 | } 64 | 65 | public function fromStringDataProvider(): array 66 | { 67 | return [ 68 | 'empty' => [ 69 | 'userInfoString' => '', 70 | 'expectedUser' => '', 71 | 'expectedPassword' => null, 72 | ], 73 | 'user only' => [ 74 | 'userInfoString' => 'user', 75 | 'expectedUser' => 'user', 76 | 'expectedPassword' => null, 77 | ], 78 | 'user and empty password' => [ 79 | 'userInfoString' => 'user:', 80 | 'expectedUser' => 'user', 81 | 'expectedPassword' => null, 82 | ], 83 | 'user and password' => [ 84 | 'userInfoString' => 'user:password', 85 | 'expectedUser' => 'user', 86 | 'expectedPassword' => 'password', 87 | ], 88 | ]; 89 | } 90 | } 91 | --------------------------------------------------------------------------------