diff --git a/Neos.Fusion/Classes/FusionObjects/ActionUriImplementation.php b/Neos.Fusion/Classes/FusionObjects/ActionUriImplementation.php index 462c150e529..4ae8b0e52c2 100644 --- a/Neos.Fusion/Classes/FusionObjects/ActionUriImplementation.php +++ b/Neos.Fusion/Classes/FusionObjects/ActionUriImplementation.php @@ -13,6 +13,7 @@ * source code. */ +use GuzzleHttp\Psr7\Uri; use Neos\Flow\Mvc\ActionRequest; use Neos\Flow\Mvc\Routing\UriBuilder; @@ -85,12 +86,24 @@ public function getAction(): ?string return $this->fusionValue('action'); } + /** + * Controller arguments that are to be handled by the router + * + * @return array + */ + public function getRoutingArguments(): array + { + $arguments = $this->fusionValue('routingArguments'); + return is_array($arguments) ? $arguments: []; + } + /** * Controller arguments * - * @return array|null + * @return array + * @deprecated to be removed with Neos 9 */ - public function getArguments(): ?array + public function getArguments(): array { $arguments = $this->fusionValue('arguments'); return is_array($arguments) ? $arguments: []; @@ -120,16 +133,28 @@ public function getSection(): ?string * Additional query parameters that won't be prefixed like $arguments (overrule $arguments) * * @return array|null + * @deprecated to be removed with Neos 9 */ public function getAdditionalParams(): ?array { return $this->fusionValue('additionalParams'); } + /** + * Query parameters that are appended to the url + * + * @return array|null + */ + public function getQueryParameters(): ?array + { + return $this->fusionValue('queryParameters'); + } + /** * Arguments to be removed from the URI. Only active if addQueryString = true * * @return array|null + * @deprecated to be removed with Neos 9 */ public function getArgumentsToBeExcludedFromQueryString(): ?array { @@ -140,6 +165,7 @@ public function getArgumentsToBeExcludedFromQueryString(): ?array * If true, the current query parameters will be kept in the URI * * @return boolean + * @deprecated to be removed with Neos 9 */ public function isAddQueryString(): bool { @@ -157,12 +183,21 @@ public function isAbsolute(): bool } /** - * @return string + * @return UriBuilder */ - public function evaluate() + public function createUriBuilder(): UriBuilder { $uriBuilder = new UriBuilder(); $uriBuilder->setRequest($this->getRequest()); + return $uriBuilder; + } + + /** + * @return string + */ + public function evaluate() + { + $uriBuilder = $this->createUriBuilder(); $format = $this->getFormat(); if ($format !== null) { @@ -195,13 +230,20 @@ public function evaluate() } try { - return $uriBuilder->uriFor( + $uriString = $uriBuilder->uriFor( $this->getAction(), - $this->getArguments(), + [... $this->getArguments(), ...$this->getRoutingArguments()], $this->getController(), $this->getPackage(), $this->getSubpackage() ); + $queryParameters = $this->getQueryParameters(); + if (empty($queryParameters)) { + return $uriString; + } + $uri = new Uri($uriString); + parse_str($uri->getQuery(), $queryParametersFromRouting); + return (string)$uri->withQuery(http_build_query([...$queryParametersFromRouting, ...$queryParameters])); } catch (\Exception $exception) { return $this->runtime->handleRenderingException($this->path, $exception); } diff --git a/Neos.Fusion/Resources/Private/Fusion/Root.fusion b/Neos.Fusion/Resources/Private/Fusion/Root.fusion index 257dc5d9ec4..e7c9210dc04 100644 --- a/Neos.Fusion/Resources/Private/Fusion/Root.fusion +++ b/Neos.Fusion/Resources/Private/Fusion/Root.fusion @@ -208,6 +208,8 @@ prototype(Neos.Fusion:ResourceUri) { prototype(Neos.Fusion:ActionUri) { @class = 'Neos\\Fusion\\FusionObjects\\ActionUriImplementation' request = ${request} + routingArguments = Neos.Fusion:DataStructure + queryParameters = Neos.Fusion:DataStructure additionalParams = Neos.Fusion:DataStructure arguments = Neos.Fusion:DataStructure argumentsToBeExcludedFromQueryString = Neos.Fusion:DataStructure diff --git a/Neos.Fusion/Tests/Unit/FusionObjects/ActionUriImplementationTest.php b/Neos.Fusion/Tests/Unit/FusionObjects/ActionUriImplementationTest.php new file mode 100644 index 00000000000..afde9f2afcf --- /dev/null +++ b/Neos.Fusion/Tests/Unit/FusionObjects/ActionUriImplementationTest.php @@ -0,0 +1,227 @@ +mockUriBuilder = $this->getMockBuilder(UriBuilder::class)->disableOriginalConstructor()->getMock(); + + $methodsToMock = [ + 'getRequest', + 'getPackage', + 'getSubpackage', + 'getController', + 'getAction', + 'getRoutingArguments', + 'getArguments', + 'getFormat', + 'getSection', + 'getAdditionalParams', + 'getQueryParameters', + 'isAbsolute', + 'getArgumentsToBeExcludedFromQueryString', + 'isAddQueryString', + 'createUriBuilder' + ]; + + $this->mockActionUriImplementation = $this->getMockBuilder(ActionUriImplementation::class)->disableOriginalConstructor()->onlyMethods($methodsToMock)->getMock(); + $this->mockActionUriImplementation->expects($this->once())->method('createUriBuilder')->willReturn($this->mockUriBuilder); + + } + + /** + * @return void + * @test + */ + public function actionIsPassedToTheUriBuilder(){ + $this->mockActionUriImplementation->expects($this->once())->method('getAction')->willReturn('hello'); + $this->mockUriBuilder->expects($this->once())->method('uriFor')->with('hello',[],null,null,null)->willReturn("http://example.com"); + $this->mockActionUriImplementation->evaluate(); + } + + /** + * @return void + * @test + */ + public function formatIsPassedToTheUriBuilder() + { + $this->mockActionUriImplementation->expects($this->once())->method('getAction')->willReturn('hello'); + $this->mockActionUriImplementation->expects($this->once())->method('getFormat')->willReturn('square'); + $this->mockUriBuilder->expects($this->once())->method('setFormat')->with('square'); + $this->mockActionUriImplementation->evaluate(); + } + + /** + * @return void + * @test + */ + public function additionalParamsArePassedToTheUriBuilder() + { + $this->mockActionUriImplementation->expects($this->once())->method('getAction')->willReturn('hello'); + $this->mockActionUriImplementation->expects($this->once())->method('getAdditionalParams')->willReturn(['nudel' => 'suppe']); + $this->mockUriBuilder->expects($this->once())->method('setArguments')->with(['nudel' => 'suppe']); + $this->mockActionUriImplementation->evaluate(); + } + + /** + * @return void + * @test + */ + public function argumentsToBeExcludedFromQueryStringArePassedToTheUriBuilder() + { + $this->mockActionUriImplementation->expects($this->once())->method('getAction')->willReturn('hello'); + $this->mockActionUriImplementation->expects($this->once())->method('getArgumentsToBeExcludedFromQueryString')->willReturn(['nudel', 'suppe']); + $this->mockUriBuilder->expects($this->once())->method('setArgumentsToBeExcludedFromQueryString')->with(['nudel', 'suppe']); + $this->mockActionUriImplementation->evaluate(); + } + + /** + * @return void + * @test + */ + public function absoluteIsPassedToTheUriBuilder() + { + $this->mockActionUriImplementation->expects($this->once())->method('getAction')->willReturn('hello'); + $this->mockActionUriImplementation->expects($this->once())->method('isAbsolute')->willReturn(true); + $this->mockUriBuilder->expects($this->once())->method('setCreateAbsoluteUri')->with(true); + $this->mockActionUriImplementation->evaluate(); + } + + /** + * @return void + * @test + */ + public function sectionIsPassedToTheUriBuilder() + { + $this->mockActionUriImplementation->expects($this->once())->method('getAction')->willReturn('hello'); + $this->mockActionUriImplementation->expects($this->once())->method('getSection')->willReturn('something'); + $this->mockUriBuilder->expects($this->once())->method('setSection')->with('something'); + $this->mockActionUriImplementation->evaluate(); + } + + /** + * @return void + * @test + */ + public function addQueryStringIsPassedToTheUriBuilder() + { + $this->mockActionUriImplementation->expects($this->once())->method('getAction')->willReturn('hello'); + $this->mockActionUriImplementation->expects($this->once())->method('isAddQueryString')->willReturn(true); + $this->mockUriBuilder->expects($this->once())->method('setAddQueryString')->with(true); + $this->mockActionUriImplementation->evaluate(); + } + + /** + * @return void + * @test + */ + public function actionPackageAndArgumentsArePassedToUriBuilder() + { + $this->mockActionUriImplementation->expects($this->once())->method('getAction')->willReturn('hello'); + $this->mockActionUriImplementation->expects($this->once())->method('getArguments')->willReturn(['test' => 123]); + $this->mockActionUriImplementation->expects($this->once())->method('getRoutingArguments')->willReturn([]); + $this->mockActionUriImplementation->expects($this->once())->method('getController')->willReturn('Special'); + $this->mockActionUriImplementation->expects($this->once())->method('getPackage')->willReturn('Vendor.Package'); + $this->mockActionUriImplementation->expects($this->once())->method('getSubpackage')->willReturn('Stuff'); + + $this->mockUriBuilder->expects($this->once())->method('uriFor')->with('hello', ['test' => 123], 'Special', 'Vendor.Package', 'Stuff')->willReturn("http://example.com"); + $this->mockActionUriImplementation->evaluate(); + } + + /** + * @return void + * @test + */ + public function actionPackageAndRoutingArgumentsArePassedToUriBuilder() + { + $this->mockActionUriImplementation->expects($this->once())->method('getAction')->willReturn('hello'); + $this->mockActionUriImplementation->expects($this->once())->method('getArguments')->willReturn([]); + $this->mockActionUriImplementation->expects($this->once())->method('getRoutingArguments')->willReturn(['test' => 123]); + $this->mockActionUriImplementation->expects($this->once())->method('getController')->willReturn('Special'); + $this->mockActionUriImplementation->expects($this->once())->method('getPackage')->willReturn('Vendor.Package'); + $this->mockActionUriImplementation->expects($this->once())->method('getSubpackage')->willReturn('Stuff'); + + $this->mockUriBuilder->expects($this->once())->method('uriFor')->with('hello', ['test' => 123], 'Special', 'Vendor.Package', 'Stuff')->willReturn("http://example.com"); + $this->mockActionUriImplementation->evaluate(); + } + + + + public function routingArgumentAndArgumentMergingDataProvider(): array + { + return [ + [[],[],[]], + [['foo' => 'bar'],[],['foo' => 'bar']], + [[],['foo' => 'bar'],['foo' => 'bar']], + [['foo' => 'bar'],['foo' => 'bam'],['foo' => 'bam']], + ]; + } + + /** + * @return void + * @dataProvider routingArgumentAndArgumentMergingDataProvider + * @test + */ + public function actionUriMergesArgumentsAndRoutingArguments($arguments, $routingArguments, $expectedMergedArgumentsToBePassedToUriBuilder): void + { + $this->mockActionUriImplementation->expects($this->once())->method('getAction')->willReturn('hello'); + $this->mockActionUriImplementation->expects($this->once())->method('getArguments')->willReturn($arguments); + $this->mockActionUriImplementation->expects($this->once())->method('getRoutingArguments')->willReturn($routingArguments) ; + $this->mockUriBuilder->expects($this->once())->method('uriFor')->with('hello',$expectedMergedArgumentsToBePassedToUriBuilder,null,null,null)->willReturn("http://hostname"); + $this->mockActionUriImplementation->evaluate(); + } + + public function queryParameterAppendingDataProvider(): array + { + return [ + ['https://example.com', ['foo' => 'bar'], 'https://example.com?foo=bar'], + ['https://example.com?foo=bar', ['bar' => 'baz'], 'https://example.com?foo=bar&bar=baz'], + ['https://example.com?foo=bar', ['foo' => 'bam'], 'https://example.com?foo=bam'], + ['https://example.com', ['foo' => ['bar' => 'baz']], 'https://example.com?foo%5Bbar%5D=baz'], + ['https://example.com?foo=bar', ['foo' => ['bar' => 'baz']], 'https://example.com?foo%5Bbar%5D=baz'], + ['https://example.com?foo[bar]=baz', ['foo' => ['blah' => 'blubb']], 'https://example.com?foo%5Bblah%5D=blubb'] + ]; + } + + /** + * @return void + * @dataProvider queryParameterAppendingDataProvider + * @test + */ + public function actionUriAppendsQueryParametersToUri($uriFromLinking, $queryParameters, $expectedFinalUri): void + { + $this->mockActionUriImplementation->expects($this->once())->method('getAction')->willReturn('hello'); + $this->mockActionUriImplementation->expects($this->once())->method('getQueryParameters')->willReturn($queryParameters); + $this->mockUriBuilder->expects($this->once())->method('uriFor')->with('hello',[],null,null,null)->willReturn($uriFromLinking); + $actualResult = $this->mockActionUriImplementation->evaluate(); + $this->assertEquals($expectedFinalUri, $actualResult); + } +} diff --git a/Neos.Neos/Classes/Fusion/NodeUriImplementation.php b/Neos.Neos/Classes/Fusion/NodeUriImplementation.php index b36ebb4a874..dc1a51445bd 100644 --- a/Neos.Neos/Classes/Fusion/NodeUriImplementation.php +++ b/Neos.Neos/Classes/Fusion/NodeUriImplementation.php @@ -11,6 +11,7 @@ * source code. */ +use GuzzleHttp\Psr7\Uri; use Neos\Flow\Annotations as Flow; use Neos\Flow\Log\ThrowableStorageInterface; use Neos\Flow\Log\Utility\LogEnvironment; @@ -86,20 +87,47 @@ public function getSection() return (string)$this->fusionValue('section'); } + /** + * Controller arguments + * + * @return array + * @deprecated + */ + public function getArguments(): array + { + $arguments = $this->fusionValue('arguments'); + return is_array($arguments) ? $arguments : []; + } + + /** + * Additional query parameters that won't be prefixed like $arguments (overrule $arguments) + * + * @return array + * @deprecated + */ + public function getAdditionalParams(): array + { + $params = $this->fusionValue('additionalParams'); + return is_array($params) ? $params : []; + } + /** * Additional query parameters that won't be prefixed like $arguments (overrule $arguments) * * @return array + * @deprecated To be removed with Neos 9 */ - public function getAdditionalParams() + public function getQueryParameters(): array { - return array_merge($this->fusionValue('additionalParams'), $this->fusionValue('arguments')); + $params = $this->fusionValue('queryParameters'); + return is_array($params) ? $params : []; } /** * Arguments to be removed from the URI. Only active if addQueryString = true * * @return array + * @deprecated To be removed with Neos 9 */ public function getArgumentsToBeExcludedFromQueryString() { @@ -110,6 +138,7 @@ public function getArgumentsToBeExcludedFromQueryString() * If true, the current query parameters will be kept in the URI * * @return boolean + * @deprecated To be removed with Neos 9 */ public function getAddQueryString() { @@ -154,17 +183,24 @@ public function evaluate() } try { - return $this->linkingService->createNodeUri( + $uriString = $this->linkingService->createNodeUri( $this->runtime->getControllerContext(), $this->getNode(), $baseNode, $this->getFormat(), $this->isAbsolute(), - $this->getAdditionalParams(), + [...$this->getArguments(), ...$this->getAdditionalParams()], $this->getSection(), $this->getAddQueryString(), $this->getArgumentsToBeExcludedFromQueryString() ); + $queryParameters = $this->getQueryParameters(); + if (empty($queryParameters)) { + return $uriString; + } + $uri = new Uri($uriString); + parse_str($uri->getQuery(), $queryParametersFromRouting); + return (string)$uri->withQuery(http_build_query([...$queryParametersFromRouting, ...$queryParameters])); } catch (NeosException $exception) { // TODO: Revisit if we actually need to store a stack trace. $logMessage = $this->throwableStorage->logThrowable($exception); diff --git a/Neos.Neos/Documentation/References/NeosFusionReference.rst b/Neos.Neos/Documentation/References/NeosFusionReference.rst index ae10955d15a..3653dd1a4f9 100644 --- a/Neos.Neos/Documentation/References/NeosFusionReference.rst +++ b/Neos.Neos/Documentation/References/NeosFusionReference.rst @@ -667,12 +667,14 @@ Built a URI to a controller action :subpackage: (string) The subpackage, empty by default :controller: (string) The controller name (e.g. ``'Registration'``) :action: (string) The action name (e.g. ``'new'``) -:arguments: (array) Arguments to the action by named key +:routingArguments: (array) That are handled by the router +:arguments: (@deprecated, array) Arguments to the action by named key :format: (string) An optional request format (e.g. ``'html'``) :section: (string) An optional fragment (hash) for the URI -:additionalParams: (array) Additional URI query parameters by named key -:addQueryString: (boolean) Whether to keep the query parameters of the current URI -:argumentsToBeExcludedFromQueryString: (array) Query parameters to exclude for ``addQueryString`` +:additionalParams: (@deprecated, array) Additional URI query parameters by named key +:queryParameters: (array) Query parameters that are appended after routing +:addQueryString: (@deprecated, boolean) Whether to keep the query parameters of the current URI +:argumentsToBeExcludedFromQueryString: (@deprecated, array) Query parameters to exclude for ``addQueryString`` :absolute: (boolean) Whether to create an absolute URI Example:: @@ -693,7 +695,7 @@ Link to the content module:: package="Neos.Neos.Ui" controller="Backend" action = 'index' - arguments.node = ${documentNode} + routingArguments.node = ${documentNode} } Link to backend modules (other than `content`):: @@ -703,7 +705,7 @@ Link to backend modules (other than `content`):: action = "index" package = "Neos.Neos" controller = "Backend\\Module" - arguments { + routingArguments { module = 'administration/sites' moduleArguments { @action = 'edit' @@ -1409,9 +1411,10 @@ Build a URI to a node. Accepts the same arguments as the node link/uri view help :node: (string/Node) A node object or a node path (relative or absolute) or empty to resolve the current document node :format: (string) An optional request format (e.g. ``'html'``) :section: (string) An optional fragment (hash) for the URI -:additionalParams: (array) Additional URI query parameters. -:argumentsToBeExcludedFromQueryString: (array) Query parameters to exclude for ``addQueryString`` -:addQueryString: (boolean) Whether to keep current query parameters, defaults to ``FALSE`` +:queryParameters: (array) Query parameters that are appended after routing +:additionalParams: (@deprecated, array) Additional URI query parameters. +:argumentsToBeExcludedFromQueryString: (@deprecated, array) Query parameters to exclude for ``addQueryString`` +:addQueryString: (@deprecated, boolean) Whether to keep current query parameters, defaults to ``FALSE`` :absolute: (boolean) Whether to create an absolute URI, defaults to ``FALSE`` :baseNodeName: (string) Base node context variable name (for relative paths), defaults to ``'documentNode'`` diff --git a/Neos.Neos/Resources/Private/Fusion/Prototypes/NodeLink.fusion b/Neos.Neos/Resources/Private/Fusion/Prototypes/NodeLink.fusion index d64b2cccef0..30baf7fe65c 100644 --- a/Neos.Neos/Resources/Private/Fusion/Prototypes/NodeLink.fusion +++ b/Neos.Neos/Resources/Private/Fusion/Prototypes/NodeLink.fusion @@ -3,6 +3,7 @@ # prototype(Neos.Neos:NodeLink) < prototype(Neos.Fusion:Tag) { node = null + queryParameters = Neos.Fusion:DataStructure additionalParams = Neos.Fusion:DataStructure arguments = Neos.Fusion:DataStructure argumentsToBeExcludedFromQueryString = Neos.Fusion:DataStructure @@ -13,6 +14,7 @@ prototype(Neos.Neos:NodeLink) < prototype(Neos.Fusion:Tag) { @context { node = ${this.node} additionalParams = ${this.additionalParams} + queryParameters = ${this.queryParameters} arguments = ${this.arguments} argumentsToBeExcludedFromQueryString = ${this.argumentsToBeExcludedFromQueryString} addQueryString = ${this.addQueryString} @@ -24,6 +26,7 @@ prototype(Neos.Neos:NodeLink) < prototype(Neos.Fusion:Tag) { attributes { href = Neos.Neos:NodeUri { node = ${node} + queryParameters = Neos.Fusion:DataStructure additionalParams = ${additionalParams} arguments = ${arguments} argumentsToBeExcludedFromQueryString = ${argumentsToBeExcludedFromQueryString} diff --git a/Neos.Neos/Resources/Private/Fusion/Prototypes/NodeUri.fusion b/Neos.Neos/Resources/Private/Fusion/Prototypes/NodeUri.fusion index b6b496f4dc1..77e855a9809 100644 --- a/Neos.Neos/Resources/Private/Fusion/Prototypes/NodeUri.fusion +++ b/Neos.Neos/Resources/Private/Fusion/Prototypes/NodeUri.fusion @@ -2,6 +2,8 @@ # prototype(Neos.Neos:NodeUri) { @class = 'Neos\\Neos\\Fusion\\NodeUriImplementation' + routingArguments = Neos.Fusion:DataStructure + queryParameters = Neos.Fusion:DataStructure additionalParams = Neos.Fusion:DataStructure arguments = Neos.Fusion:DataStructure argumentsToBeExcludedFromQueryString = Neos.Fusion:DataStructure