diff --git a/src/Hal/Serializer/CollectionNormalizer.php b/src/Hal/Serializer/CollectionNormalizer.php index 7e796a6f3a7..4a13ecfe57e 100644 --- a/src/Hal/Serializer/CollectionNormalizer.php +++ b/src/Hal/Serializer/CollectionNormalizer.php @@ -47,22 +47,22 @@ protected function getPaginationData(iterable $object, array $context = []): arr $data = [ '_links' => [ - 'self' => ['href' => IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $paginated ? $currentPage : null, $urlGenerationStrategy)], + 'self' => ['href' => IriHelper::createIri($parsed['uri'] ?? $parsed['parts'], $parsed['parameters'], $this->pageParameterName, $paginated ? $currentPage : null, $urlGenerationStrategy)], ], ]; if ($paginated) { if (null !== $lastPage) { - $data['_links']['first']['href'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, 1., $urlGenerationStrategy); - $data['_links']['last']['href'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $lastPage, $urlGenerationStrategy); + $data['_links']['first']['href'] = IriHelper::createIri($parsed['uri'] ?? $parsed['parts'], $parsed['parameters'], $this->pageParameterName, 1., $urlGenerationStrategy); + $data['_links']['last']['href'] = IriHelper::createIri($parsed['uri'] ?? $parsed['parts'], $parsed['parameters'], $this->pageParameterName, $lastPage, $urlGenerationStrategy); } if (1. !== $currentPage) { - $data['_links']['prev']['href'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage - 1., $urlGenerationStrategy); + $data['_links']['prev']['href'] = IriHelper::createIri($parsed['uri'] ?? $parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage - 1., $urlGenerationStrategy); } if ((null !== $lastPage && $currentPage !== $lastPage) || (null === $lastPage && $pageTotalItems >= $itemsPerPage)) { - $data['_links']['next']['href'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage + 1., $urlGenerationStrategy); + $data['_links']['next']['href'] = IriHelper::createIri($parsed['uri'] ?? $parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage + 1., $urlGenerationStrategy); } } diff --git a/src/Hydra/Serializer/CollectionFiltersNormalizer.php b/src/Hydra/Serializer/CollectionFiltersNormalizer.php index c527a32f544..bab5a6610e6 100644 --- a/src/Hydra/Serializer/CollectionFiltersNormalizer.php +++ b/src/Hydra/Serializer/CollectionFiltersNormalizer.php @@ -25,6 +25,7 @@ use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Uri\Rfc3986\Uri; /** * Enhances the result of collection by adding the filters applied on collection. @@ -94,10 +95,22 @@ public function normalize(mixed $object, ?string $format = null, array $context return $data; } - $requestParts = parse_url($context['request_uri'] ?? ''); - if (!\is_array($requestParts)) { - return $data; + $requestUri = $context['request_uri'] ?? ''; + if (PHP_VERSION_ID >= 80500 && \class_exists(Uri::class)) { + if (null === $uri = Uri::parse($requestUri)) { + return $data; + } + + $path = $uri->getPath(); + } else { + $requestParts = parse_url($requestUri); + if (!\is_array($requestParts)) { + return $data; + } + + $path = $requestParts['path'] ?? null; } + $currentFilters = []; foreach ($resourceFilters as $filterId) { if ($filter = $this->getFilter($filterId)) { @@ -112,7 +125,7 @@ public function normalize(mixed $object, ?string $format = null, array $context ['mapping' => $mapping, 'keys' => $keys] = $this->getSearchMappingAndKeys($operation, $resourceClass, $currentFilters, $parameters, [$this, 'getFilter']); $data[$hydraPrefix.'search'] = [ '@type' => $hydraPrefix.'IriTemplate', - $hydraPrefix.'template' => \sprintf('%s{?%s}', $requestParts['path'], implode(',', $keys)), + $hydraPrefix.'template' => \sprintf('%s{?%s}', $path, implode(',', $keys)), $hydraPrefix.'variableRepresentation' => 'BasicRepresentation', $hydraPrefix.'mapping' => $this->convertMappingToArray($mapping), ]; diff --git a/src/Hydra/Serializer/PartialCollectionViewNormalizer.php b/src/Hydra/Serializer/PartialCollectionViewNormalizer.php index c37586f7e95..82dca789eb2 100644 --- a/src/Hydra/Serializer/PartialCollectionViewNormalizer.php +++ b/src/Hydra/Serializer/PartialCollectionViewNormalizer.php @@ -171,14 +171,14 @@ private function populateDataWithCursorBasedPagination(array $data, array $parse $firstObject = current($objects); $lastObject = end($objects); - $data[$hydraPrefix.'view']['@id'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], urlGenerationStrategy: $urlGenerationStrategy); + $data[$hydraPrefix.'view']['@id'] = IriHelper::createIri($parsed['uri'] ?? $parsed['parts'], $parsed['parameters'], urlGenerationStrategy: $urlGenerationStrategy); if (false !== $lastObject && \is_array($cursorPaginationAttribute)) { - $data[$hydraPrefix.'view'][$hydraPrefix.'next'] = IriHelper::createIri($parsed['parts'], array_merge($parsed['parameters'], $this->cursorPaginationFields($cursorPaginationAttribute, 1, $lastObject)), urlGenerationStrategy: $urlGenerationStrategy); + $data[$hydraPrefix.'view'][$hydraPrefix.'next'] = IriHelper::createIri($parsed['uri'] ?? $parsed['parts'], array_merge($parsed['parameters'], $this->cursorPaginationFields($cursorPaginationAttribute, 1, $lastObject)), urlGenerationStrategy: $urlGenerationStrategy); } if (false !== $firstObject && \is_array($cursorPaginationAttribute)) { - $data[$hydraPrefix.'view'][$hydraPrefix.'previous'] = IriHelper::createIri($parsed['parts'], array_merge($parsed['parameters'], $this->cursorPaginationFields($cursorPaginationAttribute, -1, $firstObject)), urlGenerationStrategy: $urlGenerationStrategy); + $data[$hydraPrefix.'view'][$hydraPrefix.'previous'] = IriHelper::createIri($parsed['uri'] ?? $parsed['parts'], array_merge($parsed['parameters'], $this->cursorPaginationFields($cursorPaginationAttribute, -1, $firstObject)), urlGenerationStrategy: $urlGenerationStrategy); } return $data; diff --git a/src/Hydra/State/JsonStreamerProcessor.php b/src/Hydra/State/JsonStreamerProcessor.php index 967316ccd8f..ba717927aad 100644 --- a/src/Hydra/State/JsonStreamerProcessor.php +++ b/src/Hydra/State/JsonStreamerProcessor.php @@ -33,6 +33,7 @@ use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\JsonStreamer\StreamWriterInterface; use Symfony\Component\TypeInfo\Type; +use Uri\Rfc3986\Uri; /** * @implements ProcessorInterface @@ -83,8 +84,8 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $collection->view = $this->getPartialCollectionView($data, $requestUri, $this->pageParameterName, $this->enabledParameterName, $operation->getUrlGenerationStrategy() ?? $this->urlGenerationStrategy); if ($operation->getParameters()) { - $parts = parse_url($requestUri); - $collection->search = $this->getSearch($parts['path'] ?? '', $operation); + $path = PHP_VERSION_ID >= 80500 && \class_exists(Uri::class) ? Uri::parse($requestUri)?->getPath() : ($parts['path'] ?? ''); + $collection->search = $this->getSearch($path, $operation); } if ($data instanceof PaginatorInterface) { diff --git a/src/Hydra/State/Util/PaginationHelperTrait.php b/src/Hydra/State/Util/PaginationHelperTrait.php index 82c30651ac6..d04fb30eede 100644 --- a/src/Hydra/State/Util/PaginationHelperTrait.php +++ b/src/Hydra/State/Util/PaginationHelperTrait.php @@ -26,16 +26,16 @@ private function getPaginationIri(array $parsed, ?float $currentPage, ?float $la $first = $last = $previous = $next = null; if (null !== $lastPage) { - $first = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $pageParameterName, 1., $urlGenerationStrategy); - $last = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $pageParameterName, $lastPage, $urlGenerationStrategy); + $first = IriHelper::createIri($parsed['uri'] ?? $parsed['parts'], $parsed['parameters'], $pageParameterName, 1., $urlGenerationStrategy); + $last = IriHelper::createIri($parsed['uri'] ?? $parsed['parts'], $parsed['parameters'], $pageParameterName, $lastPage, $urlGenerationStrategy); } if (1. !== $currentPage) { - $previous = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $pageParameterName, $currentPage - 1., $urlGenerationStrategy); + $previous = IriHelper::createIri($parsed['uri'] ?? $parsed['parts'], $parsed['parameters'], $pageParameterName, $currentPage - 1., $urlGenerationStrategy); } if ((null !== $lastPage && $currentPage < $lastPage) || (null === $lastPage && $pageTotalItems >= $itemsPerPage)) { - $next = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $pageParameterName, $currentPage + 1., $urlGenerationStrategy); + $next = IriHelper::createIri($parsed['uri'] ?? $parsed['parts'], $parsed['parameters'], $pageParameterName, $currentPage + 1., $urlGenerationStrategy); } return [ @@ -65,7 +65,7 @@ private function getPartialCollectionView(mixed $object, string $requestUri, str $appliedFilters = $parsed['parameters']; unset($appliedFilters[$enabledParameterName]); - $id = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $pageParameterName, $paginated ? $currentPage : null, $urlGenerationStrategy); + $id = IriHelper::createIri($parsed['uri'] ?? $parsed['parts'], $parsed['parameters'], $pageParameterName, $paginated ? $currentPage : null, $urlGenerationStrategy); if (!$paginated && $appliedFilters) { return new PartialCollectionView($id); diff --git a/src/JsonApi/Serializer/CollectionNormalizer.php b/src/JsonApi/Serializer/CollectionNormalizer.php index 1c1f362ce0d..4adbd5ed42f 100644 --- a/src/JsonApi/Serializer/CollectionNormalizer.php +++ b/src/JsonApi/Serializer/CollectionNormalizer.php @@ -48,22 +48,22 @@ protected function getPaginationData(iterable $object, array $context = []): arr $data = [ 'links' => [ - 'self' => IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $paginated ? $currentPage : null, $urlGenerationStrategy), + 'self' => IriHelper::createIri($parsed['uri'] ?? $parsed['parts'], $parsed['parameters'], $this->pageParameterName, $paginated ? $currentPage : null, $urlGenerationStrategy), ], ]; if ($paginated) { if (null !== $lastPage) { - $data['links']['first'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, 1., $urlGenerationStrategy); - $data['links']['last'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $lastPage, $urlGenerationStrategy); + $data['links']['first'] = IriHelper::createIri($parsed['uri'] ?? $parsed['parts'], $parsed['parameters'], $this->pageParameterName, 1., $urlGenerationStrategy); + $data['links']['last'] = IriHelper::createIri($parsed['uri'] ?? $parsed['parts'], $parsed['parameters'], $this->pageParameterName, $lastPage, $urlGenerationStrategy); } if (1. !== $currentPage) { - $data['links']['prev'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage - 1., $urlGenerationStrategy); + $data['links']['prev'] = IriHelper::createIri($parsed['uri'] ?? $parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage - 1., $urlGenerationStrategy); } if (null !== $lastPage && $currentPage !== $lastPage || null === $lastPage && $pageTotalItems >= $itemsPerPage) { - $data['links']['next'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage + 1., $urlGenerationStrategy); + $data['links']['next'] = IriHelper::createIri($parsed['uri'] ?? $parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage + 1., $urlGenerationStrategy); } } diff --git a/src/Metadata/Tests/Util/IriHelperTest.php b/src/Metadata/Tests/Util/IriHelperTest.php index 5fcc2ac98d9..912f8388eae 100644 --- a/src/Metadata/Tests/Util/IriHelperTest.php +++ b/src/Metadata/Tests/Util/IriHelperTest.php @@ -17,6 +17,7 @@ use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\IriHelper; use PHPUnit\Framework\TestCase; +use Uri\Rfc3986\Uri; /** * @author Kévin Dunglas @@ -25,6 +26,10 @@ class IriHelperTest extends TestCase { public function testHelpers(): void { + if (PHP_VERSION_ID >= 80500 && class_exists(Uri::class)) { + self::markTestSkipped('Parsing url with former "parse_url()" method is not available after PHP8.5 and ext-uri'); + } + $parsed = [ 'parts' => [ 'path' => '/hello.json', @@ -70,6 +75,56 @@ public function testHelpersWithNetworkPath(): void $this->assertSame('//foo:bar@localhost:443/hello.json?foo=bar&bar=3&page=2#foo', IriHelper::createIri($parsed['parts'], $parsed['parameters'], 'page', 2., UrlGeneratorInterface::NET_PATH)); } + public function testHelpersWithRFC3986(): void + { + if (PHP_VERSION_ID < 80500 || !class_exists(Uri::class)) { + self::markTestSkipped('RFC 3986 URI parser needs PHP 8.5 or higher and php-uri extension.'); + } + + $parsed = [ + 'uri' => new Uri('/hello.json?foo=bar&page=2&bar=3'), + 'parameters' => [ + 'foo' => 'bar', + 'bar' => '3', + ], + ]; + + + $this->assertEquals($parsed, IriHelper::parseIri('/hello.json?foo=bar&page=2&bar=3', 'page')); + $this->assertSame('/hello.json?foo=bar&bar=3&page=2', IriHelper::createIri($parsed['uri'], $parsed['parameters'], 'page', 2.)); + } + + public function testHelpersWithNetworkPathAndRFC3986(): void + { + if (PHP_VERSION_ID < 80500 || !class_exists(Uri::class)) { + self::markTestSkipped('RFC 3986 URI parser needs PHP 8.5 or higher and php-uri extension.'); + } + + $parsed = [ + 'uri' => $uri = new Uri('/hello.json') + ->withQuery('foo=bar&page=2&bar=3') + ->withScheme('http') + ->withHost('localhost') + ->withUserInfo('foo:bar') + ->withPort(8080) + ->withFragment('foo'), + 'parameters' => [ + 'foo' => 'bar', + 'bar' => '3', + ], + ]; + + $this->assertSame('//foo:bar@localhost:8080/hello.json?foo=bar&bar=3&page=2#foo', IriHelper::createIri($parsed['uri'], $parsed['parameters'], 'page', 2., UrlGeneratorInterface::NET_PATH)); + + $parsed['uri'] = $uri->withScheme(null); + + $this->assertSame('//foo:bar@localhost:8080/hello.json?foo=bar&bar=3&page=2#foo', IriHelper::createIri($parsed['uri'], $parsed['parameters'], 'page', 2., UrlGeneratorInterface::NET_PATH)); + + $parsed['uri'] = $uri->withPort(443); + + $this->assertSame('//foo:bar@localhost:443/hello.json?foo=bar&bar=3&page=2#foo', IriHelper::createIri($parsed['uri'], $parsed['parameters'], 'page', 2., UrlGeneratorInterface::NET_PATH)); + } + public function testParseIriWithInvalidUrl(): void { $this->expectException(InvalidArgumentException::class); diff --git a/src/Metadata/Util/IriHelper.php b/src/Metadata/Util/IriHelper.php index 7803562cf94..a6830f63bbb 100644 --- a/src/Metadata/Util/IriHelper.php +++ b/src/Metadata/Util/IriHelper.php @@ -16,6 +16,8 @@ use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\State\Util\RequestParser; +use Uri\InvalidUriException; +use Uri\Rfc3986\Uri; /** * Parses and creates IRIs. @@ -30,12 +32,35 @@ private function __construct() { } + public static function parseIri(string $iri, string $pageParameterName): array + { + if (PHP_VERSION_ID < 80500 || !\class_exists(Uri::class)) { + return self::parseLegacyIri($iri, $pageParameterName); + } + + try { + $uri = new Uri($iri); + } catch (InvalidUriException $e) { + throw new InvalidArgumentException(\sprintf('The request URI "%s" is malformed.', $iri), previous: $e); + } + + $parameters = []; + if (null !== $query = $uri->getQuery()) { + $parameters = RequestParser::parseRequestParams($query); + + // Remove existing page parameter + unset($parameters[$pageParameterName]); + } + + return ['uri' => $uri, 'parameters' => $parameters]; + } + /** * Parses and standardizes the request IRI. * * @throws InvalidArgumentException */ - public static function parseIri(string $iri, string $pageParameterName): array + private static function parseLegacyIri(string $iri, string $pageParameterName): array { $parts = parse_url($iri); if (false === $parts) { @@ -58,14 +83,29 @@ public static function parseIri(string $iri, string $pageParameterName): array * * @param int $urlGenerationStrategy */ - public static function createIri(array $parts, array $parameters, ?string $pageParameterName = null, ?float $page = null, $urlGenerationStrategy = UrlGeneratorInterface::ABS_PATH): string + public static function createIri(array|Uri $parts, array $parameters, ?string $pageParameterName = null, ?float $page = null, $urlGenerationStrategy = UrlGeneratorInterface::ABS_PATH): string { if (null !== $page && null !== $pageParameterName) { $parameters[$pageParameterName] = $page; } $query = http_build_query($parameters, '', '&', \PHP_QUERY_RFC3986); - $parts['query'] = preg_replace('/%5B\d+%5D/', '%5B%5D', $query); + $queryParts = preg_replace('/%5B\d+%5D/', '%5B%5D', $query); + + if ($parts instanceof Uri) { + $uri = $parts + ->withQuery('' !== $queryParts ? $queryParts : null) + ->withScheme(UrlGeneratorInterface::ABS_URL === $urlGenerationStrategy && null === $parts->getScheme() ? ($parts->getPort() === 443 ? 'https' : 'http') : null) + ; + + if (null === $urlGenerationStrategy) { + $uri = $uri->withScheme(null)->withUserInfo(null)->withPort(null)->withHost(null); + } + + return $uri->toString(); + } + + $parts['query'] = $queryParts; $url = ''; if ((UrlGeneratorInterface::ABS_URL === $urlGenerationStrategy || UrlGeneratorInterface::NET_PATH === $urlGenerationStrategy) && isset($parts['host'])) { diff --git a/src/State/Util/HttpResponseHeadersTrait.php b/src/State/Util/HttpResponseHeadersTrait.php index ec80d85b913..9f6a71e39d2 100644 --- a/src/State/Util/HttpResponseHeadersTrait.php +++ b/src/State/Util/HttpResponseHeadersTrait.php @@ -26,6 +26,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface as SymfonyHttpExceptionInterface; +use Uri\Rfc3986\Uri; /** * Shares the logic to create API Platform's headers. @@ -97,7 +98,11 @@ private function getHeaders(Request $request, HttpOperation $operation, array $c } } - $requestParts = parse_url($request->getRequestUri()); + $query = PHP_VERSION_ID >= 80500 && \class_exists(Uri::class) + ? Uri::parse($context['request_uri'] ?? '')?->getQuery() + : $requestParts['query'] ?? null + ; + if ($this->iriConverter && !isset($headers['Content-Location'])) { try { $iri = null; @@ -109,8 +114,8 @@ private function getHeaders(Request $request, HttpOperation $operation, array $c if ($iri && 'GET' !== $method) { $location = \sprintf('%s.%s', $iri, $request->getRequestFormat()); - if (isset($requestParts['query'])) { - $location .= '?'.$requestParts['query']; + if (isset($query)) { + $location .= '?'.$query; } $headers['Content-Location'] = $location;