From a7a32bafe4d77ae0dd48abfe312a1e22b61255f4 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Mon, 22 Apr 2024 15:41:01 +0200 Subject: [PATCH] [Uri] Add component --- composer.json | 1 + src/Symfony/Component/Uri/.gitattributes | 3 + .../Uri/.github/PULL_REQUEST_TEMPLATE.md | 8 + .../.github/workflows/check-subtree-split.yml | 37 +++ src/Symfony/Component/Uri/.gitignore | 3 + src/Symfony/Component/Uri/CHANGELOG.md | 7 + .../Uri/Exception/InvalidUriException.php | 20 ++ .../Exception/UnresolvableUriException.php | 20 ++ .../Component/Uri/FragmentTextDirective.php | 46 +++ src/Symfony/Component/Uri/LICENSE | 19 ++ src/Symfony/Component/Uri/QueryString.php | 184 +++++++++++ src/Symfony/Component/Uri/README.md | 69 ++++ .../Uri/Tests/FragmentTextDirectiveTest.php | 47 +++ .../Component/Uri/Tests/QueryStringTest.php | 146 +++++++++ src/Symfony/Component/Uri/Tests/UriTest.php | 296 ++++++++++++++++++ src/Symfony/Component/Uri/Uri.php | 203 ++++++++++++ src/Symfony/Component/Uri/composer.json | 28 ++ src/Symfony/Component/Uri/phpunit.xml.dist | 30 ++ 18 files changed, 1167 insertions(+) create mode 100644 src/Symfony/Component/Uri/.gitattributes create mode 100644 src/Symfony/Component/Uri/.github/PULL_REQUEST_TEMPLATE.md create mode 100644 src/Symfony/Component/Uri/.github/workflows/check-subtree-split.yml create mode 100644 src/Symfony/Component/Uri/.gitignore create mode 100644 src/Symfony/Component/Uri/CHANGELOG.md create mode 100644 src/Symfony/Component/Uri/Exception/InvalidUriException.php create mode 100644 src/Symfony/Component/Uri/Exception/UnresolvableUriException.php create mode 100644 src/Symfony/Component/Uri/FragmentTextDirective.php create mode 100644 src/Symfony/Component/Uri/LICENSE create mode 100644 src/Symfony/Component/Uri/QueryString.php create mode 100644 src/Symfony/Component/Uri/README.md create mode 100644 src/Symfony/Component/Uri/Tests/FragmentTextDirectiveTest.php create mode 100644 src/Symfony/Component/Uri/Tests/QueryStringTest.php create mode 100644 src/Symfony/Component/Uri/Tests/UriTest.php create mode 100644 src/Symfony/Component/Uri/Uri.php create mode 100644 src/Symfony/Component/Uri/composer.json create mode 100644 src/Symfony/Component/Uri/phpunit.xml.dist diff --git a/composer.json b/composer.json index 5fd5177abe4c3..8c913985d77d2 100644 --- a/composer.json +++ b/composer.json @@ -112,6 +112,7 @@ "symfony/twig-bundle": "self.version", "symfony/type-info": "self.version", "symfony/uid": "self.version", + "symfony/uri": "self.version", "symfony/validator": "self.version", "symfony/var-dumper": "self.version", "symfony/var-exporter": "self.version", diff --git a/src/Symfony/Component/Uri/.gitattributes b/src/Symfony/Component/Uri/.gitattributes new file mode 100644 index 0000000000000..5b728ea25ac99 --- /dev/null +++ b/src/Symfony/Component/Uri/.gitattributes @@ -0,0 +1,3 @@ +Tests export-ignore +/phpunit.xml.dist export-ignore +/.git* export-ignore diff --git a/src/Symfony/Component/Uri/.github/PULL_REQUEST_TEMPLATE.md b/src/Symfony/Component/Uri/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000000..4689c4dad430e --- /dev/null +++ b/src/Symfony/Component/Uri/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/symfony + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/Symfony/Component/Uri/.github/workflows/check-subtree-split.yml b/src/Symfony/Component/Uri/.github/workflows/check-subtree-split.yml new file mode 100644 index 0000000000000..16be48bae3113 --- /dev/null +++ b/src/Symfony/Component/Uri/.github/workflows/check-subtree-split.yml @@ -0,0 +1,37 @@ +name: Check subtree split + +on: + pull_request_target: + +jobs: + close-pull-request: + runs-on: ubuntu-latest + + steps: + - name: Close pull request + uses: actions/github-script@v6 + with: + script: | + if (context.repo.owner === "symfony") { + github.rest.issues.createComment({ + owner: "symfony", + repo: context.repo.repo, + issue_number: context.issue.number, + body: ` + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/symfony + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! + ` + }); + + github.rest.pulls.update({ + owner: "symfony", + repo: context.repo.repo, + pull_number: context.issue.number, + state: "closed" + }); + } diff --git a/src/Symfony/Component/Uri/.gitignore b/src/Symfony/Component/Uri/.gitignore new file mode 100644 index 0000000000000..c49a5d8df5c65 --- /dev/null +++ b/src/Symfony/Component/Uri/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/Uri/CHANGELOG.md b/src/Symfony/Component/Uri/CHANGELOG.md new file mode 100644 index 0000000000000..f2f5402e3dd96 --- /dev/null +++ b/src/Symfony/Component/Uri/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +7.2 +--- + + * Add the component as experimental diff --git a/src/Symfony/Component/Uri/Exception/InvalidUriException.php b/src/Symfony/Component/Uri/Exception/InvalidUriException.php new file mode 100644 index 0000000000000..d55d9a6f98dbe --- /dev/null +++ b/src/Symfony/Component/Uri/Exception/InvalidUriException.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Uri\Exception; + +final class InvalidUriException extends \RuntimeException +{ + public function __construct(string $uri) + { + parent::__construct(sprintf('The URI "%s" is invalid.', $uri)); + } +} diff --git a/src/Symfony/Component/Uri/Exception/UnresolvableUriException.php b/src/Symfony/Component/Uri/Exception/UnresolvableUriException.php new file mode 100644 index 0000000000000..f2de6c6b5863f --- /dev/null +++ b/src/Symfony/Component/Uri/Exception/UnresolvableUriException.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Uri\Exception; + +final class UnresolvableUriException extends \RuntimeException +{ + public function __construct(string $uri) + { + parent::__construct(sprintf('The URI "%s" cannot be used as a base URI in a resolution.', $uri)); + } +} diff --git a/src/Symfony/Component/Uri/FragmentTextDirective.php b/src/Symfony/Component/Uri/FragmentTextDirective.php new file mode 100644 index 0000000000000..792716b05c927 --- /dev/null +++ b/src/Symfony/Component/Uri/FragmentTextDirective.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Uri; + +/** + * As defined in the Scroll to Text Fragment proposal. + * + * @see https://wicg.github.io/scroll-to-text-fragment/ + * + * @experimental + * + * @author Alexandre Daubois + */ +final class FragmentTextDirective implements \Stringable +{ + public function __construct( + public string $start, + public ?string $end = null, + public ?string $prefix = null, + public ?string $suffix = null, + ) { + } + + /** + * Dash, comma and ampersand are encoded, @see https://wicg.github.io/scroll-to-text-fragment/#syntax. + */ + public function __toString(): string + { + $encode = static fn (string $value) => strtr($value, ['-' => '%2D', ',' => '%2C', '&' => '%26']); + + return ':~:text=' + .($this->prefix ? $encode($this->prefix).'-,' : '') + .$encode($this->start) + .($this->end ? ','.$encode($this->end) : '') + .($this->suffix ? ',-'.$encode($this->suffix) : ''); + } +} diff --git a/src/Symfony/Component/Uri/LICENSE b/src/Symfony/Component/Uri/LICENSE new file mode 100644 index 0000000000000..e374a5c8339d3 --- /dev/null +++ b/src/Symfony/Component/Uri/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2024-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/Uri/QueryString.php b/src/Symfony/Component/Uri/QueryString.php new file mode 100644 index 0000000000000..266e411472fdb --- /dev/null +++ b/src/Symfony/Component/Uri/QueryString.php @@ -0,0 +1,184 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Uri; + +/** + * @experimental + * + * @author Alexandre Daubois + */ +final class QueryString implements \Stringable +{ + /** + * @var array + */ + private array $parameters = []; + + /** + * Parses a URI. + * + * Unlike `parse_str()`, this method does not overwrite duplicate keys but instead + * returns an array of all values for each key: + * + * QueryString::parse('foo=1&foo=2&bar=3'); // stored as ['foo' => ['1', '2'], 'bar' => '3'] + * + * `+` are supported in parameter keys and not replaced by an underscore: + * + * QueryString::parse('foo+bar=1'); // stored as ['foo bar' => '1'] + * + * `.` and `_` are supported distinct in parameter keys: + * + * QueryString::parse('foo.bar=1'); // stored as ['foo.bar' => '1'] + * QueryString::parse('foo_bar=1'); // stored as ['foo_bar' => '1'] + */ + public static function parse(string $query): self + { + $parts = explode('&', $query); + $queryString = new self(); + + foreach ($parts as $part) { + if ('' === $part) { + continue; + } + + $part = explode('=', $part, 2); + $key = urldecode($part[0]); + // keys without value will be stored as empty strings, as "parse_str()" does + $value = isset($part[1]) ? urldecode($part[1]) : ''; + + // take care of nested arrays + if (preg_match_all('/\[(.*?)]/', $key, $matches)) { + $nestedKeys = $matches[1]; + // nest the value inside the extracted keys + $value = array_reduce(array_reverse($nestedKeys), static function ($carry, $key) { + return [$key => $carry]; + }, $value); + + $key = strstr($key, '[', true); + } + + if ($queryString->has($key)) { + $queryString->set($key, self::deepMerge((array) $queryString->get($key), (array) $value)); + } else { + $queryString->set($key, $value); + } + } + + return $queryString; + } + + public function has(string $key): bool + { + return \array_key_exists($key, $this->parameters); + } + + /** + * Get the first value of the first tuple whose name is `$key`. + * + * @see https://url.spec.whatwg.org/#interface-urlsearchparams + * + * @return string|string[]|null + */ + public function get(string $key): string|array|null + { + $param = $this->parameters[$key] ?? null; + + if (\is_array($param) && array_is_list($param)) { + return $param[0]; + } + + return $param; + } + + /** + * Get all values of the tuple whose name is `$key`. + * + * @see https://url.spec.whatwg.org/#interface-urlsearchparams + * + * @return string|string[]|null + */ + public function getAll(string $key): string|array|null + { + return $this->parameters[$key] ?? null; + } + + public function set(string $key, array|string|null $value): self + { + $this->parameters[$key] = $value; + + return $this; + } + + public function remove(string $key): self + { + unset($this->parameters[$key]); + + return $this; + } + + /** + * @return array + */ + public function all(): array + { + return $this->parameters; + } + + public function __toString(): string + { + $parts = []; + foreach (self::flattenParameters($this->parameters) as $key => $values) { + foreach ((array) $values as $value) { + $parts[] = strtr($key, [' ' => '+']).'='.urlencode($value); + } + } + + return implode('&', $parts); + } + + private static function flattenParameters(array $parameters, string $prefix = ''): array + { + $result = []; + foreach ($parameters as $key => $value) { + $newKey = '' === $prefix ? $key : $prefix.'['.$key.']'; + + if (\is_array($value)) { + $result += self::flattenParameters($value, $newKey); + } else { + $result[$newKey] = $value; + } + } + + return $result; + } + + private static function deepMerge(array $parameters, array $newParameters): array + { + foreach ($newParameters as $key => $value) { + if (\is_array($value) && isset($parameters[$key]) && \is_array($parameters[$key])) { + $parameters[$key] = self::deepMerge($parameters[$key], $value); + } elseif (isset($parameters[$key])) { + $merge = array_merge((array) $parameters[$key], (array) $value); + + if (\is_string($key)) { + $parameters[$key] = $merge; + } else { + $parameters = $merge; + } + } else { + $parameters[$key] = $value; + } + } + + return $parameters; + } +} diff --git a/src/Symfony/Component/Uri/README.md b/src/Symfony/Component/Uri/README.md new file mode 100644 index 0000000000000..ce2a40c0b8519 --- /dev/null +++ b/src/Symfony/Component/Uri/README.md @@ -0,0 +1,69 @@ +Uri Component +============= + +The Uri component is a low-level Symfony components that enhances PHP built-in +features. The primary goal is to have a consistent and object-oriented approach +for `parse_url()` and `parse_str()` functions. + +Getting Started +--------------- + +```bash +composer require symfony/uri +``` + +Usage +----- + +```php +use Symfony\Component\Uri\QueryString; +use Symfony\Component\Uri\Uri; + +require 'vendor/autoload.php'; + +$uri = Uri::parse('https://example.com/foo/bar?baz=qux&arr[key]=foo&arr[another]=bar'); +$uri = $uri->withFragmentTextDirective('start', 'end', 'prefix', 'suffix'); + +echo (string) $uri."\n"; // https://example.com/foo/bar#:~:text=prefix-,start,end,-suffix + +$queryString = $uri->query; +$baz = $queryString->get('baz'); // 'qux' +$arr = $queryString->get('arr'); // ['key' => 'foo', 'another' => 'bar'] + +// Uri decodes the authority part of the URI +$uri = Uri::parse('https://user:p%40ss@host:123/path?query#fragment'); +echo $uri->password."\n"; // 'p@ss' + +// QueryString makes a difference between '.' and '_' +$queryString = QueryString::parse('foo.bar=1&foo_bar=2'); +echo $queryString->get('foo.bar')."\n"; // '1' +echo $queryString->get('foo_bar')."\n"; // '2' +``` + +Notable Differences With PHP Functions +-------------------------------------- + +### `parse_url()` + + * `parse_url()` **does not** decode the auth component of the URL (user and + pass). This makes it impossible to use the `parse_url()` function to parse + a URL with a username or password that contains a colon (`:`) or + an `@` character. + +### `parse_str()` + + * `parse_str()` overwrites any duplicate field in the query parameter + (e.g. `?foo=bar&foo=baz` will return `['foo' => 'baz']`). `foo` should be an + array instead with the two values. + * `parse_str()` replaces `.` in the query parameter keys with `_`, thus no + distinction can be done between `foo.bar` and `foo_bar`. + * `parse_str()` doesn't "support" `+` in the parameter keys and replaces them + with `_` instead of a space. + +Resources +--------- + + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Uri/Tests/FragmentTextDirectiveTest.php b/src/Symfony/Component/Uri/Tests/FragmentTextDirectiveTest.php new file mode 100644 index 0000000000000..6aa142da2a224 --- /dev/null +++ b/src/Symfony/Component/Uri/Tests/FragmentTextDirectiveTest.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Uri\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Uri\FragmentTextDirective; + +/** + * @covers \Symfony\Component\Uri\FragmentTextDirective + */ +class FragmentTextDirectiveTest extends TestCase +{ + /** + * @dataProvider provideValidFragmentTextDirectives + */ + public function testToString(FragmentTextDirective $fragmentTextDirective, string $expected) + { + $this->assertSame($expected, (string) $fragmentTextDirective); + } + + public function testToStringEncodesSpecialCharacters() + { + $fragmentTextDirective = new FragmentTextDirective('st&rt', 'e,nd', 'prefix-', '-&suffix'); + + $this->assertSame(':~:text=prefix%2D-,st%26rt,e%2Cnd,-%2D%26suffix', (string) $fragmentTextDirective); + } + + public static function provideValidFragmentTextDirectives(): iterable + { + yield [new FragmentTextDirective('start'), ':~:text=start']; + yield [new FragmentTextDirective('start', 'end'), ':~:text=start,end']; + yield [new FragmentTextDirective('start', 'end', 'prefix'), ':~:text=prefix-,start,end']; + yield [new FragmentTextDirective('start', 'end', 'prefix', 'suffix'), ':~:text=prefix-,start,end,-suffix']; + yield [new FragmentTextDirective('start', prefix: 'prefix', suffix: 'suffix'), ':~:text=prefix-,start,-suffix']; + yield [new FragmentTextDirective('start', suffix: 'suffix'), ':~:text=start,-suffix']; + yield [new FragmentTextDirective('start', prefix: 'prefix'), ':~:text=prefix-,start']; + } +} diff --git a/src/Symfony/Component/Uri/Tests/QueryStringTest.php b/src/Symfony/Component/Uri/Tests/QueryStringTest.php new file mode 100644 index 0000000000000..30ceb88efc8ea --- /dev/null +++ b/src/Symfony/Component/Uri/Tests/QueryStringTest.php @@ -0,0 +1,146 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Uri\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Uri\QueryString; + +/** + * @covers \Symfony\Component\Uri\QueryString + */ +class QueryStringTest extends TestCase +{ + public function testBasicString() + { + $queryString = QueryString::parse('foo=1&bar=2&baz=3'); + + $this->assertSame('1', $queryString->get('foo')); + $this->assertSame('2', $queryString->get('bar')); + $this->assertSame('3', $queryString->get('baz')); + } + + public function testQueryStringWithDotAndUnderscore() + { + $queryString = QueryString::parse('foo.bar=1&foo_bar=2'); + + $this->assertSame('1', $queryString->get('foo.bar')); + $this->assertSame('2', $queryString->get('foo_bar')); + } + + public function testQueryStringWithPlus() + { + $queryString = QueryString::parse('foo+bar=1'); + + $this->assertSame('1', $queryString->get('foo bar')); + } + + public function testQueryStringWithArray() + { + $queryString = QueryString::parse('foo=1&foo=2&bar=3'); + + $this->assertSame('1', $queryString->get('foo')); + $this->assertSame(['1', '2'], $queryString->getAll('foo')); + $this->assertSame('3', $queryString->get('bar')); + } + + public function testQueryStringWithNestedArrays() + { + $queryString = QueryString::parse('foo[bar]=1&foo[baz][qux]=2'); + + $this->assertSame('1', $queryString->get('foo')['bar']); + $this->assertSame('2', $queryString->get('foo')['baz']['qux']); + } + + public function testEmptyParameter() + { + $queryString = QueryString::parse('foo=1&bar=&baz=3'); + + $this->assertSame('1', $queryString->get('foo')); + $this->assertSame('', $queryString->get('bar')); + $this->assertSame('3', $queryString->get('baz')); + } + + public function testEmptyQueryString() + { + $queryString = QueryString::parse(''); + + $this->assertEmpty($queryString->all()); + } + + public function testMultiEqualSignInParameter() + { + $queryString = QueryString::parse('foo=1=2&bar=3&baz=4'); + + $this->assertSame('1=2', $queryString->get('foo')); + $this->assertSame('3', $queryString->get('bar')); + $this->assertSame('4', $queryString->get('baz')); + } + + public function testSetParameter() + { + $queryString = QueryString::parse('foo=1&bar=2&baz=3'); + $queryString->set('bar', 4); + + $this->assertSame('1', $queryString->get('foo')); + $this->assertSame('4', $queryString->get('bar')); + $this->assertSame('3', $queryString->get('baz')); + } + + public function testGetUnknownParameter() + { + $queryString = QueryString::parse('foo=1&bar=2&baz=3'); + + $this->assertNull($queryString->get('unknown')); + } + + public function testToString() + { + $queryString = QueryString::parse('foo=1&bar=2&baz=3'); + + $this->assertSame('foo=1&bar=2&baz=3', (string) $queryString); + } + + public function testToStringWithArray() + { + $queryString = QueryString::parse('foo=1&foo=2&bar=3'); + + $this->assertSame('foo[0]=1&foo[1]=2&bar=3', (string) $queryString); + } + + public function testToStringWithNestedArray() + { + $queryString = QueryString::parse('foo[bar][0]=1&foo[bar][1]=2&foo[bar][5]=2'); + + $this->assertSame('foo[bar][0]=1&foo[bar][1]=2&foo[bar][5]=2', (string) $queryString); + } + + public function testToStringWithNestedArrayWithoutIndex() + { + $queryString = QueryString::parse('foo[bar]=1&foo[bar]=2'); + + $this->assertSame('foo[bar][0]=1&foo[bar][1]=2', (string) $queryString); + } + + public function testToStringWithSpaces() + { + $queryString = QueryString::parse('foo=1&bar=2&baz=3&foo bar=4'); + + $this->assertSame('foo=1&bar=2&baz=3&foo+bar=4', (string) $queryString); + } + + public function testToStringWithDotAndUnderscores() + { + $queryString = QueryString::parse('foo.bar=1&foo_bar=2'); + + $this->assertSame('foo.bar=1&foo_bar=2', (string) $queryString); + } +} diff --git a/src/Symfony/Component/Uri/Tests/UriTest.php b/src/Symfony/Component/Uri/Tests/UriTest.php new file mode 100644 index 0000000000000..dd527c05f1836 --- /dev/null +++ b/src/Symfony/Component/Uri/Tests/UriTest.php @@ -0,0 +1,296 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Uri\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Uri\Exception\InvalidUriException; +use Symfony\Component\Uri\Exception\UnresolvableUriException; +use Symfony\Component\Uri\Uri; + +/** + * @covers \Symfony\Component\Uri\Uri + */ +class UriTest extends TestCase +{ + /** + * @dataProvider provideValidUris + */ + public function testParseValidUriAreReconstructed(string $uri) + { + $parsedUri = Uri::parse($uri); + + $this->assertSame($uri, (string) $parsedUri); + } + + public function testReconstructUriWithZeros() + { + $uri = Uri::parse('https://0:0@example.com'); + + $this->assertSame('https://0:0@example.com', (string) $uri); + } + + public function testUriWithoutDoubleSlash() + { + $uri = Uri::parse('mailto:user@example.com'); + + $this->assertSame('mailto', $uri->scheme); + $this->assertNull($uri->user); + $this->assertNull($uri->password); + $this->assertNull($uri->host); + $this->assertNull($uri->port); + $this->assertSame('user@example.com', $uri->path); + $this->assertNull($uri->query); + $this->assertNull($uri->fragment); + + $this->assertSame('mailto:user@example.com', (string) $uri); + } + + public function testUriWithOneSlashThrowsException() + { + $this->expectException(InvalidUriException::class); + $this->expectExceptionMessage('The URI "https:/example.com" is invalid.'); + + Uri::parse('https:/example.com'); + } + + public static function provideValidUris(): iterable + { + yield ['https://example.com']; + yield ['https://example.com/']; + yield ['https://example.com/foo/bar']; + yield ['https://example.com?foo=bar']; + yield ['https://example.com#foo']; + yield ['https://example.com?foo=bar#baz']; + yield ['https://example.com:8080']; + yield ['https://example.com:8080/']; + yield ['https://example.com:8080/foo/bar']; + yield ['https://example.com:8080?foo=bar']; + yield ['https://example.com:8080#foo']; + yield ['https://example.com:8080?foo=bar#baz']; + yield ['https://user:pass@example.com']; + yield ['https://user@example.com']; + } + + public function testBasicUri() + { + $uri = Uri::parse('https://example.com'); + + $this->assertSame('https', $uri->scheme); + $this->assertNull($uri->user); + $this->assertNull($uri->password); + $this->assertSame('example.com', $uri->host); + $this->assertNull($uri->port); + $this->assertNull($uri->path); + $this->assertNull($uri->query); + $this->assertNull($uri->fragment); + } + + public function testUriWithPath() + { + $uri = Uri::parse('https://example.com/foo/bar'); + + $this->assertSame('https', $uri->scheme); + $this->assertNull($uri->user); + $this->assertNull($uri->password); + $this->assertSame('example.com', $uri->host); + $this->assertNull($uri->port); + $this->assertSame('/foo/bar', $uri->path); + $this->assertNull($uri->query); + $this->assertNull($uri->fragment); + } + + public function testUriWithQueryString() + { + $uri = Uri::parse('https://example.com?foo=bar'); + + $this->assertSame('https', $uri->scheme); + $this->assertNull($uri->user); + $this->assertNull($uri->password); + $this->assertSame('example.com', $uri->host); + $this->assertNull($uri->port); + $this->assertNull($uri->path); + $this->assertSame('foo=bar', (string) $uri->query); + $this->assertNull($uri->fragment); + } + + public function testUriWithFragment() + { + $uri = Uri::parse('https://example.com#foo'); + + $this->assertSame('https', $uri->scheme); + $this->assertNull($uri->user); + $this->assertNull($uri->password); + $this->assertSame('example.com', $uri->host); + $this->assertNull($uri->port); + $this->assertNull($uri->path); + $this->assertNull($uri->query); + $this->assertSame('foo', $uri->fragment); + } + + public function testUriWithUserAndPassword() + { + $uri = Uri::parse('https://user:pass@example.com'); + + $this->assertSame('https', $uri->scheme); + $this->assertSame('user', $uri->user); + $this->assertSame('pass', $uri->password); + $this->assertSame('example.com', $uri->host); + $this->assertNull($uri->port); + $this->assertNull($uri->path); + $this->assertNull($uri->query); + $this->assertNull($uri->fragment); + } + + public function testUriWithColonInUsernameAndAtInPasswordIsDecoded() + { + $uri = Uri::parse('https://user%3A:p%40ss@example.com'); + + $this->assertSame('https', $uri->scheme); + $this->assertSame('user:', $uri->user); + $this->assertSame('p@ss', $uri->password); + $this->assertSame('example.com', $uri->host); + + // ensure stringed version re-encodes the user and password + $this->assertSame('https://user%3A:p%40ss@example.com', (string) $uri); + } + + public function testEncodedCharactersInQueryParameterAreNotDecoded() + { + $uri = Uri::parse('https://example.com?foo=bar%3D'); + + $this->assertSame('https', $uri->scheme); + $this->assertNull($uri->user); + $this->assertNull($uri->password); + $this->assertSame('example.com', $uri->host); + $this->assertNull($uri->port); + $this->assertNull($uri->path); + $this->assertSame('foo=bar%3D', (string) $uri->query); + $this->assertNull($uri->fragment); + } + + public function testMissingSchemeThrowsException() + { + $this->expectException(InvalidUriException::class); + $this->expectExceptionMessage('The URI "//example.com" is invalid.'); + + Uri::parse('//example.com'); + } + + public function testOnlySchemeIsRequired() + { + $uri = Uri::parse('https:'); + + $this->assertSame('https', $uri->scheme); + $this->assertNull($uri->user); + $this->assertNull($uri->password); + $this->assertNull($uri->host); + $this->assertNull($uri->port); + $this->assertNull($uri->path); + $this->assertNull($uri->query); + $this->assertNull($uri->fragment); + } + + public function testEmptyStringThrowsException() + { + $this->expectException(InvalidUriException::class); + $this->expectExceptionMessage('The URI "" is invalid.'); + + Uri::parse(''); + } + + public function testNonUriStringThrowsException() + { + $this->expectException(InvalidUriException::class); + $this->expectExceptionMessage('The URI "foo" is invalid.'); + + Uri::parse('foo'); + } + + public function testUriWithFragmentTextDirectives() + { + $uri = Uri::parse('https://example.com'); + $newUri = $uri->withFragmentTextDirective('start', 'end', 'prefix', 'suffix'); + + $this->assertNull($uri->fragmentTextDirective); + $this->assertNotNull($newUri->fragmentTextDirective); + + $this->assertNotSame($uri, $newUri); + + $this->assertSame('https://example.com#:~:text=prefix-,start,end,-suffix', (string) $newUri); + } + + public function testUriWithExistingFragmentWithFragmentTextDirectives() + { + $uri = Uri::parse('https://example.com#existing'); + $newUri = $uri->withFragmentTextDirective('start', 'end', 'prefix', 'suffix'); + + $this->assertNull($uri->fragmentTextDirective); + $this->assertNotNull($newUri->fragmentTextDirective); + + $this->assertNotSame($uri, $newUri); + + $this->assertSame('https://example.com#existing:~:text=prefix-,start,end,-suffix', (string) $newUri); + } + + public function testUriWithIdnHostAsAscii() + { + $uri = Uri::parse('https://bücher.ch'); + $this->assertSame('bücher.ch', $uri->host); + + $uri = $uri->withIdnHostAsAscii(); + $this->assertSame('xn--bcher-kva.ch', $uri->host); + } + + public function testUriWithIdnHostAsUnicode() + { + $uri = Uri::parse('https://xn--bcher-kva.ch'); + $this->assertSame('xn--bcher-kva.ch', $uri->host); + + $uri = $uri->withIdnHostAsUnicode(); + $this->assertSame('bücher.ch', $uri->host); + } + + /** + * @dataProvider provideResolveUri + */ + public function testResolveUri(string $baseUri, string $relativeUri, string $expectedUri) + { + $resolvedUri = Uri::resolve($relativeUri, $baseUri); + + $this->assertSame($expectedUri, $resolvedUri); + } + + public function testInvalidUriAsBaseToResolve() + { + $this->expectException(UnresolvableUriException::class); + $this->expectExceptionMessage('The URI "mailto:user@example.com" cannot be used as a base URI in a resolution.'); + + Uri::resolve('bar', 'mailto:user@example.com'); + } + + public static function provideResolveUri(): iterable + { + yield 'Single-level absolute path' => ['https://example.com/foo', '/bar', 'https://example.com/bar']; + yield 'Multi-level Absolute path' => ['https://example.com', '/foo/bar', 'https://example.com/foo/bar']; + yield 'Single-level relative path' => ['https://example.com/foo', 'bar', 'https://example.com/bar']; + yield 'Single-level relative path with trailing slash' => ['https://example.com/foo/', 'bar', 'https://example.com/foo/bar']; + yield 'Single-level parent' => ['https://example.com/foo/', '../bar', 'https://example.com/bar']; + yield 'Multi-level parent' => ['https://example.com/foo/', '../../bar', 'https://example.com/bar']; + yield 'Absolute path with trailing slash' => ['https://example.com/foo/', '/bar', 'https://example.com/bar']; + yield 'Different domains' => ['https://example.com/foo/', 'https://example.org/bar', 'https://example.org/bar']; + yield 'Different domains without double-slash' => ['https://example.com/foo/', 'mailto:user@example.com', 'mailto:user@example.com']; + yield 'Erase query string and fragment of base URI' => ['https://example.com/foo?foo=bar#baz', '/bar', 'https://example.com/bar']; + yield 'Erase query string and fragment of base URI with trailing slash' => ['https://example.com/foo/?foo=bar#baz', 'bar', 'https://example.com/foo/bar']; + yield 'Keep query string and fragment of relative URI' => ['https://example.com/foo/', '/bar?foo=bar#baz', 'https://example.com/bar?foo=bar#baz']; + yield 'Relative URI is empty' => ['https://example.com/foo', '', 'https://example.com/foo']; + } +} diff --git a/src/Symfony/Component/Uri/Uri.php b/src/Symfony/Component/Uri/Uri.php new file mode 100644 index 0000000000000..f1465770e1611 --- /dev/null +++ b/src/Symfony/Component/Uri/Uri.php @@ -0,0 +1,203 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Uri; + +use Symfony\Component\Uri\Exception\InvalidUriException; +use Symfony\Component\Uri\Exception\UnresolvableUriException; + +/** + * Parses a URI and allows to resolve relative URIs, as defined + * in RFC 3986 (https://tools.ietf.org/html/rfc3986). + * + * @experimental + * + * @author Alexandre Daubois + */ +final class Uri implements \Stringable +{ + private bool $hasDoubleSlashAuthority = false; + + private const URI_GLOBAL_REGEX = '/^(?:(?P[^:\/?#]+):)?(?:\/\/(?P[^\/?#]*))?(?P[^?#]*)(?:\?(?P[^#]*))?(?:#(?P.*))?$/'; + private const URI_AUTHORITY_REGEX = '/^(?:(?P[^:@]*)(?::(?P[^@]*))?@)?(?P[^:]*)(?::(?P\d*))?$/'; + + public function __construct( + public string $scheme, + #[\SensitiveParameter] + public ?string $user = null, + #[\SensitiveParameter] + public ?string $password = null, + public ?string $host = null, + public ?int $port = null, + public ?string $path = null, + public ?QueryString $query = null, + public ?string $fragment = null, + public ?FragmentTextDirective $fragmentTextDirective = null, + ) { + } + + /** + * Parses a URL. + * + * The `user` and `pass` keys are url-decoded automatically when parsing. + * + * @throws InvalidUriException + */ + public static function parse(#[\SensitiveParameter] string $uri): static + { + preg_match(self::URI_GLOBAL_REGEX, $uri, $matches); + if (!$matches || !isset($matches['scheme']) || '' === $matches['scheme']) { + throw new InvalidUriException($uri); + } + + if (preg_match('~'.$matches['scheme'].':/(?!/)~', $uri)) { + throw new InvalidUriException($uri); + } + + if (isset($matches['authority'])) { + if (!str_contains($uri, '://') && '' !== $matches['authority']) { + throw new InvalidUriException($uri); + } + + preg_match(self::URI_AUTHORITY_REGEX, $matches['authority'], $authMatches); + + $matches = array_merge($matches, $authMatches); + unset($matches['authority']); + } + + $matches = array_filter($matches, static fn (string $value): bool => '' !== $value); + + $uriInstance = new static( + $matches['scheme'], + isset($matches['user']) ? rawurldecode($matches['user']) : null, + isset($matches['pass']) ? rawurldecode($matches['pass']) : null, + $matches['host'] ?? null, + $matches['port'] ?? null, + $matches['path'] ?? null, + isset($matches['query']) ? QueryString::parse($matches['query']) : null, + $matches['fragment'] ?? null, + ); + + if (str_contains($uri, '://')) { + $uriInstance->hasDoubleSlashAuthority = true; + } + + return $uriInstance; + } + + /** + * Resolves a relative URI against a base URI. + * + * Uri::resolve('/foo/bar', 'http://example.com'); // http://example.com/foo/bar + * Uri::resolve('/bar', 'http://example.com/foo'); // http://example.com/bar + * Uri::resolve('bar', 'http://example.com/foo'); // http://example.com/bar + * Uri::resolve('bar', 'http://example.com/foo/'); // http://example.com/foo/bar + * Uri::resolve('../bar', 'http://example.com/foo/'); // http://example.com/bar + * Uri::resolve('../../bar', 'http://example.com/foo/'); // http://example.com/bar + * Uri::resolve('/bar', 'http://example.com/foo/'); // http://example.com/bar + * Uri::resolve('http://example.org/bar', 'http://example.com/foo/'); // http://example.org/bar + */ + public static function resolve(string $relativeUri, self|string $baseUri): string + { + if ('' === $relativeUri) { + return (string) $baseUri; + } + + // the relative URI is an absolute URI + if (preg_match('/^[a-zA-Z][a-zA-Z\d+\-.]*:/', $relativeUri)) { + return $relativeUri; + } + + $baseUri = $baseUri instanceof self ? $baseUri : self::parse($baseUri); + if (!$baseUri->hasDoubleSlashAuthority) { + throw new UnresolvableUriException((string) $baseUri); + } + + $baseUri->query = null; + $baseUri->fragment = null; + $baseUri->fragmentTextDirective = null; + + if (!str_ends_with($baseUri->path ?? '', '/')) { + // when the base URI does not end with a slash, the path is erased + $baseUri->path = null; + } + + $relativeParts = explode('/', $relativeUri); + $baseParts = $baseUri->path && !str_starts_with($relativeUri, '/') ? + explode('/', trim($baseUri->path, '/')) + : []; + + $resolvedPathSegments = $baseParts; + foreach ($relativeParts as $segment) { + if ('..' === $segment) { + array_pop($resolvedPathSegments); + } elseif ('.' !== $segment && '' !== $segment) { + $resolvedPathSegments[] = $segment; + } + } + + $finalUri = clone $baseUri; + $finalUri->path = '/'.implode('/', $resolvedPathSegments); + + return (string) $finalUri; + } + + /** + * Returns a new instance with a new fragment text directive. + */ + public function withFragmentTextDirective(string $start, ?string $end = null, ?string $prefix = null, ?string $suffix = null): static + { + $uri = clone $this; + $uri->fragmentTextDirective = new FragmentTextDirective($start, $end, $prefix, $suffix); + + return $uri; + } + + /** + * Returns a new instance with the host part of the URI converted to ASCII. + * + * @see https://www.unicode.org/reports/tr46/#ToASCII + */ + public function withIdnHostAsAscii(): static + { + $uri = clone $this; + $uri->host = idn_to_ascii($uri->host, \IDNA_NONTRANSITIONAL_TO_ASCII, \INTL_IDNA_VARIANT_UTS46); + + return $uri; + } + + /** + * Returns a new instance with the host part of the URI converted to Unicode. + * + * @see https://www.unicode.org/reports/tr46/#ToUnicode + */ + public function withIdnHostAsUnicode(): static + { + $uri = clone $this; + $uri->host = idn_to_utf8($uri->host, \IDNA_NONTRANSITIONAL_TO_UNICODE, \INTL_IDNA_VARIANT_UTS46); + + return $uri; + } + + public function __toString() + { + return $this->scheme.':' + .($this->hasDoubleSlashAuthority ? '//' : '') + .(null !== $this->user ? (null !== $this->password ? rawurlencode($this->user).':'.rawurlencode($this->password) : urlencode($this->user)).'@' : '') + .($this->host ?: '') + .($this->port ? ':'.$this->port : '') + .($this->path ?? '') + .($this->query ? '?'.$this->query : '') + .($this->fragment || $this->fragmentTextDirective ? '#' : '') + .($this->fragment ?? '') + .($this->fragmentTextDirective ?? ''); + } +} diff --git a/src/Symfony/Component/Uri/composer.json b/src/Symfony/Component/Uri/composer.json new file mode 100644 index 0000000000000..35e7d2b547c4f --- /dev/null +++ b/src/Symfony/Component/Uri/composer.json @@ -0,0 +1,28 @@ +{ + "name": "symfony/uri", + "type": "library", + "description": "Parses and manipulates URLs", + "keywords": ["uri", "url", "query string" ,"symfony"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Alexandre Daubois", + "email": "alex.daubois@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.2" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Uri\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/Uri/phpunit.xml.dist b/src/Symfony/Component/Uri/phpunit.xml.dist new file mode 100644 index 0000000000000..d0519815d8615 --- /dev/null +++ b/src/Symfony/Component/Uri/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Resources + ./Tests + ./vendor + + +