diff --git a/composer.json b/composer.json index eed626cef342..a37d8fb327b0 100644 --- a/composer.json +++ b/composer.json @@ -36,6 +36,7 @@ "league/commonmark": "^2.2.1", "league/flysystem": "^3.25.1", "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", "monolog/monolog": "^3.0", "nesbot/carbon": "^2.72.2|^3.4", "nunomaduro/termwind": "^2.0", diff --git a/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php b/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php index c137442a9b12..5e84a62f7201 100644 --- a/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php +++ b/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php @@ -27,6 +27,7 @@ use Illuminate\Support\AggregateServiceProvider; use Illuminate\Support\Defer\DeferredCallbackCollection; use Illuminate\Support\Facades\URL; +use Illuminate\Support\Uri; use Illuminate\Testing\LoggedExceptionCollection; use Illuminate\Testing\ParallelTestingServiceProvider; use Illuminate\Validation\ValidationException; @@ -89,6 +90,7 @@ public function register() $this->registerDumper(); $this->registerRequestValidation(); $this->registerRequestSignatureValidation(); + $this->registerUriUrlGeneration(); $this->registerDeferHandler(); $this->registerExceptionTracking(); $this->registerExceptionRenderer(); @@ -188,6 +190,16 @@ public function registerRequestSignatureValidation() }); } + /** + * Register the "defer" function termination handler. + * + * @return void + */ + protected function registerUriUrlGeneration() + { + Uri::setUrlGeneratorResolver(fn () => app('url')); + } + /** * Register the "defer" function termination handler. * diff --git a/src/Illuminate/Http/Request.php b/src/Illuminate/Http/Request.php index 64a2e85d7214..37e1694e559c 100644 --- a/src/Illuminate/Http/Request.php +++ b/src/Illuminate/Http/Request.php @@ -11,6 +11,7 @@ use Illuminate\Support\Str; use Illuminate\Support\Traits\Conditionable; use Illuminate\Support\Traits\Macroable; +use Illuminate\Support\Uri; use RuntimeException; use Symfony\Component\HttpFoundation\Exception\SessionNotFoundException; use Symfony\Component\HttpFoundation\InputBag; @@ -91,6 +92,16 @@ public function method() return $this->getMethod(); } + /** + * Get a URI instance for the request. + * + * @return \Illuminate\Support\Uri + */ + public function uri() + { + return Uri::of($this->fullUrl()); + } + /** * Get the root URL for the application. * diff --git a/src/Illuminate/Support/Uri.php b/src/Illuminate/Support/Uri.php new file mode 100644 index 000000000000..fbc524b1796d --- /dev/null +++ b/src/Illuminate/Support/Uri.php @@ -0,0 +1,347 @@ +uri = $uri instanceof UriInterface ? $uri : LeagueUri::new((string) $uri); + } + + /** + * Create a new URI instance. + */ + public static function of(UriInterface|Stringable|string $uri = ''): static + { + return new static($uri); + } + + /** + * Get a URI instance of an absolute URL for the given path. + */ + public static function to(string $path): static + { + return new static(call_user_func(static::$urlGeneratorResolver)->to($path)); + } + + /** + * Get a URI instance for a named route. + * + * @param \BackedEnum|string $name + * @param mixed $parameters + * @param bool $absolute + * @return static + * + * @throws \Symfony\Component\Routing\Exception\RouteNotFoundException|\InvalidArgumentException + */ + public static function route($name, $parameters = [], $absolute = true): static + { + return new static(call_user_func(static::$urlGeneratorResolver)->route($name, $parameters, $absolute)); + } + + /** + * Get the URI's scheme. + */ + public function scheme(): ?string + { + return $this->uri->getScheme(); + } + + /** + * Get the user from the URI. + */ + public function user(bool $withPassword = false): ?string + { + return $withPassword + ? $this->uri->getUserInfo() + : $this->uri->getUsername(); + } + + /** + * Get the password from the URI. + */ + public function password(): ?string + { + return $this->uri->getPassword(); + } + + /** + * Get the URI's host. + */ + public function host(): ?string + { + return $this->uri->getHost(); + } + + /** + * Get the URI's port. + */ + public function port(): ?int + { + return $this->uri->getPort(); + } + + /** + * Get the URI's path. + * + * Empty or missing paths are returned as a single "/". + */ + public function path(): ?string + { + $path = trim((string) $this->uri->getPath(), '/'); + + return $path === '' ? '/' : $path; + } + + /** + * Get the URI's query string. + */ + public function query(): UriQueryString + { + return new UriQueryString($this); + } + + /** + * Get the URI's fragment. + */ + public function fragment(): ?string + { + return $this->uri->getFragment(); + } + + /** + * Specify the scheme of the URI. + */ + public function withScheme(Stringable|string $scheme): static + { + return new static($this->uri->withScheme($scheme)); + } + + /** + * Specify the user and password for the URI. + */ + public function withUser(Stringable|string|null $user, #[SensitiveParameter] Stringable|string|null $password = null): static + { + return new static($this->uri->withUserInfo($user, $password)); + } + + /** + * Specify the host of the URI. + */ + public function withHost(Stringable|string $host): static + { + return new static($this->uri->withHost($host)); + } + + /** + * Specify the port of the URI. + */ + public function withPort(int|null $port): static + { + return new static($this->uri->withPort($port)); + } + + /** + * Specify the path of the URI. + */ + public function withPath(Stringable|string $path): static + { + return new static($this->uri->withPath(Str::start((string) $path, '/'))); + } + + /** + * Merge new query parameters into the URI. + */ + public function withQuery(array $query, bool $merge = true): static + { + foreach ($query as $key => $value) { + if ($value instanceof UrlRoutable) { + $query[$key] = $value->getRouteKey(); + } + } + + if ($merge) { + $mergedQuery = $this->query()->all(); + + foreach ($query as $key => $value) { + data_set($mergedQuery, $key, $value); + } + + $newQuery = $mergedQuery; + } else { + $newQuery = []; + + foreach ($query as $key => $value) { + data_set($newQuery, $key, $value); + } + } + + return new static($this->uri->withQuery(Arr::query($newQuery))); + } + + /** + * Merge new query parameters into the URI if they are not already in the query string. + */ + public function withQueryIfMissing(array $query): static + { + $currentQuery = $this->query(); + + foreach ($query as $key => $value) { + if (! $currentQuery->missing($key)) { + Arr::forget($query, $key); + } + } + + return $this->withQuery($query); + } + + /** + * Push a value onto the end of a query string parameter that is a list. + */ + public function pushOntoQuery(string $key, mixed $value): static + { + $currentValue = data_get($this->query()->all(), $key); + + $values = Arr::wrap($value); + + return $this->withQuery([$key => match (true) { + is_array($currentValue) && array_is_list($currentValue) => array_values(array_unique([...$currentValue, ...$values])), + is_array($currentValue) => [...$currentValue, ...$values], + ! is_null($currentValue) => [$currentValue, ...$values], + default => $values, + }]); + } + + /** + * Remove the given query parameters from the URI. + */ + public function withoutQuery(array $keys): static + { + return $this->replaceQuery(Arr::except($this->query()->all(), $keys)); + } + + /** + * Specify new query parameters for the URI. + */ + public function replaceQuery(array $query): static + { + return $this->withQuery($query, merge: false); + } + + /** + * Specify the fragment of the URI. + */ + public function withFragment(string $fragment): static + { + return new static($this->uri->withFragment($fragment)); + } + + /** + * Create a redirect HTTP response for the given URI. + */ + public function redirect(int $status = 302, array $headers = []): RedirectResponse + { + return new RedirectResponse($this->value(), $status, $headers); + } + + /** + * Create an HTTP response that represents the object. + * + * @param \Illuminate\Http\Request $request + * @return \Symfony\Component\HttpFoundation\Response + */ + public function toResponse($request) + { + return new RedirectResponse($this->value()); + } + + /** + * Get content as a string of HTML. + * + * @return string + */ + public function toHtml() + { + return $this->value(); + } + + /** + * Get the decoded string representation of the URI. + */ + public function decode(): string + { + if (empty($this->query()->toArray())) { + return $this->value(); + } + + return Str::replace(Str::after($this->value(), '?'), $this->query()->decode(), $this->value()); + } + + /** + * Get the string representation of the URI. + */ + public function value(): string + { + return (string) $this; + } + + /** + * Determine if the URI is currently an empty string. + */ + public function isEmpty(): bool + { + return trim($this->value()) === ''; + } + + /** + * Set the URL generator resolver. + */ + public static function setUrlGeneratorResolver(Closure $urlGeneratorResolver): void + { + static::$urlGeneratorResolver = $urlGeneratorResolver; + } + + /** + * Get the underlying URI instance. + */ + public function getUri(): UriInterface + { + return $this->uri; + } + + /** + * Get the string representation of the URI. + */ + public function __toString(): string + { + return $this->uri->toString(); + } +} diff --git a/src/Illuminate/Support/UriQueryString.php b/src/Illuminate/Support/UriQueryString.php new file mode 100644 index 000000000000..a9ae5cead397 --- /dev/null +++ b/src/Illuminate/Support/UriQueryString.php @@ -0,0 +1,95 @@ +toArray(); + + if (! $keys) { + return $query; + } + + $results = []; + + foreach (is_array($keys) ? $keys : func_get_args() as $key) { + Arr::set($results, $key, Arr::get($query, $key)); + } + + return $results; + } + + /** + * Retrieve data from the instance. + * + * @param string|null $key + * @param mixed $default + * @return mixed + */ + protected function data($key = null, $default = null) + { + return $this->get($key, $default); + } + + /** + * Get a query string parameter. + */ + public function get(?string $key = null, mixed $default = null): mixed + { + return data_get($this->toArray(), $key, $default); + } + + /** + * Get the URL decoded version of the query string. + */ + public function decode(): string + { + return rawurldecode((string) $this); + } + + /** + * Get the string representation of the query string. + */ + public function value(): string + { + return (string) $this; + } + + /** + * Convert the query string into an array. + */ + public function toArray() + { + return QueryString::extract($this->value()); + } + + /** + * Get the string representation of the query string. + */ + public function __toString(): string + { + return (string) $this->uri->getUri()->getQuery(); + } +} diff --git a/src/Illuminate/Support/composer.json b/src/Illuminate/Support/composer.json index 986309901b08..1c2a052136bf 100644 --- a/src/Illuminate/Support/composer.json +++ b/src/Illuminate/Support/composer.json @@ -47,11 +47,12 @@ } }, "suggest": { - "illuminate/filesystem": "Required to use the composer class (^11.0).", + "illuminate/filesystem": "Required to use the Composer class (^11.0).", "laravel/serializable-closure": "Required to use the once function (^1.3).", "league/commonmark": "Required to use Str::markdown() and Stringable::markdown() (^2.0.2).", + "league/uri": "Required to use the Uri class (^7.5.1).", "ramsey/uuid": "Required to use Str::uuid() (^4.7).", - "symfony/process": "Required to use the composer class (^7.0).", + "symfony/process": "Required to use the Composer class (^7.0).", "symfony/uid": "Required to use Str::ulid() (^7.0).", "symfony/var-dumper": "Required to use the dd function (^7.0).", "vlucas/phpdotenv": "Required to use the Env class and env helper (^5.6.1)." diff --git a/tests/Support/SupportUriTest.php b/tests/Support/SupportUriTest.php new file mode 100644 index 000000000000..95e05ea8791e --- /dev/null +++ b/tests/Support/SupportUriTest.php @@ -0,0 +1,129 @@ +assertEquals('https', $uri->scheme()); + $this->assertNull($uri->user()); + $this->assertNull($uri->password()); + $this->assertEquals('laravel.com', $uri->host()); + $this->assertNull($uri->port()); + $this->assertEquals('docs/installation', $uri->path()); + $this->assertEquals([], $uri->query()->toArray()); + $this->assertEquals('', (string) $uri->query()); + $this->assertEquals('', $uri->query()->decode()); + $this->assertNull($uri->fragment()); + $this->assertEquals($originalUri, (string) $uri); + + $uri = Uri::of('https://taylor:password@laravel.com/docs/installation?version=1#hello'); + + $this->assertEquals('taylor', $uri->user()); + $this->assertEquals('password', $uri->password()); + $this->assertEquals('hello', $uri->fragment()); + $this->assertEquals(['version' => 1], $uri->query()->all()); + $this->assertEquals(1, $uri->query()->integer('version')); + } + + public function test_complicated_query_string_parsing() + { + $uri = Uri::of('https://example.com/users?key_1=value&key_2[sub_field]=value&key_3[]=value&key_4[9]=value&key_5[][][foo][9]=bar&key.6=value&flag_value'); + + $this->assertEquals([ + 'key_1' => 'value', + 'key_2' => [ + 'sub_field' => 'value', + ], + 'key_3' => [ + 'value', + ], + 'key_4' => [ + 9 => 'value', + ], + 'key_5' => [ + [ + [ + 'foo' => [ + 9 => 'bar', + ], + ], + ], + ], + 'key.6' => 'value', + 'flag_value' => '', + ], $uri->query()->all()); + + $this->assertEquals('key_1=value&key_2[sub_field]=value&key_3[]=value&key_4[9]=value&key_5[][][foo][9]=bar&key.6=value&flag_value', $uri->query()->decode()); + } + + public function test_uri_building() + { + $uri = Uri::of(); + + $uri = $uri->withHost('laravel.com') + ->withScheme('https') + ->withUser('taylor', 'password') + ->withPath('/docs/installation') + ->withPort(80) + ->withQuery(['version' => 1]) + ->withFragment('hello'); + + $this->assertEquals('https://taylor:password@laravel.com:80/docs/installation?version=1#hello', (string) $uri); + } + + public function test_complicated_query_string_manipulation() + { + $uri = Uri::of('https://laravel.com'); + + $uri = $uri->withQuery([ + 'name' => 'Taylor', + 'age' => 38, + 'role' => [ + 'title' => 'Developer', + 'focus' => 'PHP', + ], + 'tags' => [ + 'person', + 'employee', + ], + 'flag' => '', + ])->withoutQuery(['name']); + + $this->assertEquals('age=38&role[title]=Developer&role[focus]=PHP&tags[0]=person&tags[1]=employee&flag=', $uri->query()->decode()); + $this->assertEquals('name=Taylor', $uri->replaceQuery(['name' => 'Taylor'])->query()->decode()); + + // Push onto multi-value and missing items... + $uri = Uri::of('https://laravel.com?tags[]=foo'); + + $this->assertEquals(['tags' => ['foo', 'bar']], $uri->pushOntoQuery('tags', 'bar')->query()->all()); + $this->assertEquals(['tags' => ['foo', 'bar', 'baz']], $uri->pushOntoQuery('tags', ['bar', 'baz'])->query()->all()); + $this->assertEquals(['tags' => ['foo'], 'names' => ['Taylor']], $uri->pushOntoQuery('names', 'Taylor')->query()->all()); + + // Push onto single value item... + $uri = Uri::of('https://laravel.com?tag=foo'); + + $this->assertEquals(['tag' => ['foo', 'bar']], $uri->pushOntoQuery('tag', 'bar')->query()->all()); + } + + public function test_query_strings_with_dots_can_be_replaced_or_merged_consistently() + { + $uri = Uri::of('https://dot.test/?foo.bar=baz'); + + $this->assertEquals('foo.bar=baz&foo[bar]=zab', $uri->withQuery(['foo.bar' => 'zab'])->query()->decode()); + $this->assertEquals('foo[bar]=zab', $uri->replaceQuery(['foo.bar' => 'zab'])->query()->decode()); + } + + public function test_decoding_the_entire_uri() + { + $uri = Uri::of('https://laravel.com/docs/11.x/installation')->withQuery(['tags' => ['first', 'second']]); + + $this->assertEquals('https://laravel.com/docs/11.x/installation?tags[0]=first&tags[1]=second', $uri->decode()); + } +}