diff --git a/src/Application/DefaultLinkGenerator.php b/src/Application/DefaultLinkGenerator.php new file mode 100644 index 000000000..a659a611c --- /dev/null +++ b/src/Application/DefaultLinkGenerator.php @@ -0,0 +1,324 @@ +createRequest($component, $parts['path'] . ($parts['signal'] ? '!' : ''), $args, $mode ?? 'link'); + $relative = $mode === 'link' && !$parts['absolute'] && !$component?->getPresenter()->absoluteUrls; + return $mode === 'forward' || $mode === 'test' + ? null + : $this->requestToUrl($request, $relative) . $parts['fragment']; + } + + + /** + * @param string $destination in format "[[[module:]presenter:]action | signal! | this | @alias]" + * @param string $mode forward|redirect|link + * @throws UI\InvalidLinkException + * @internal + */ + public function createRequest( + ?UI\Component $component, + string $destination, + array $args, + string $mode, + ): Request + { + // note: createRequest supposes that saveState(), run() & tryCall() behaviour is final + + $this->lastRequest = null; + $refPresenter = $component?->getPresenter(); + $path = $destination; + + if (($component && !$component instanceof UI\Presenter) || str_ends_with($destination, '!')) { + [$cname, $signal] = Helpers::splitName(rtrim($destination, '!')); + if ($cname !== '') { + $component = $component->getComponent(strtr($cname, ':', '-')); + } + + if ($signal === '') { + throw new UI\InvalidLinkException('Signal must be non-empty string.'); + } + + $path = 'this'; + } + + if ($path[0] === '@') { + if (!$this->presenterFactory instanceof PresenterFactory) { + throw new \LogicException('Link aliasing requires PresenterFactory service.'); + } + $path = ':' . $this->presenterFactory->getAlias(substr($path, 1)); + } + + $current = false; + [$presenter, $action] = Helpers::splitName($path); + if ($presenter === '') { + if (!$refPresenter) { + throw new \LogicException("Presenter must be specified in '$destination'."); + } + $action = $path === 'this' ? $refPresenter->getAction() : $action; + $presenter = $refPresenter->getName(); + $presenterClass = $refPresenter::class; + + } else { + if ($presenter[0] === ':') { // absolute + $presenter = substr($presenter, 1); + if (!$presenter) { + throw new UI\InvalidLinkException("Missing presenter name in '$destination'."); + } + } elseif ($refPresenter) { // relative + [$module, , $sep] = Helpers::splitName($refPresenter->getName()); + $presenter = $module . $sep . $presenter; + } + + try { + $presenterClass = $this->presenterFactory?->getPresenterClass($presenter); + } catch (InvalidPresenterException $e) { + throw new UI\InvalidLinkException($e->getMessage(), 0, $e); + } + } + + // PROCESS SIGNAL ARGUMENTS + if (isset($signal)) { // $component must be StatePersistent + $reflection = new UI\ComponentReflection($component::class); + if ($signal === 'this') { // means "no signal" + $signal = ''; + if (array_key_exists(0, $args)) { + throw new UI\InvalidLinkException("Unable to pass parameters to 'this!' signal."); + } + } elseif (!str_contains($signal, UI\Component::NameSeparator)) { + // counterpart of signalReceived() & tryCall() + + $method = $reflection->getSignalMethod($signal); + if (!$method) { + throw new UI\InvalidLinkException("Unknown signal '$signal', missing handler {$reflection->getName()}::{$component::formatSignalMethod($signal)}()"); + } + + $this->validateLinkTarget($refPresenter, $method, "signal '$signal'" . ($component === $refPresenter ? '' : ' in ' . $component::class), $mode); + + // convert indexed parameters to named + UI\ParameterConverter::toParameters($method, $args, [], $missing); + } + + // counterpart of StatePersistent + if ($args && array_intersect_key($args, $reflection->getPersistentParams())) { + $component->saveState($args); + } + + if ($args && $component !== $refPresenter) { + $prefix = $component->getUniqueId() . UI\Component::NameSeparator; + foreach ($args as $key => $val) { + unset($args[$key]); + $args[$prefix . $key] = $val; + } + } + } + + // PROCESS ARGUMENTS + if (is_subclass_of($presenterClass, UI\Presenter::class)) { + if ($action === '') { + $action = UI\Presenter::DefaultAction; + } + + $current = $refPresenter && ($action === '*' || strcasecmp($action, $refPresenter->getAction()) === 0) && $presenterClass === $refPresenter::class; + + $reflection = new UI\ComponentReflection($presenterClass); + $this->validateLinkTarget($refPresenter, $reflection, "presenter '$presenter'", $mode); + + foreach (array_intersect_key($reflection->getParameters(), $args) as $name => $param) { + if ($args[$name] === $param['def']) { + $args[$name] = null; // value transmit is unnecessary + } + } + + // counterpart of run() & tryCall() + if ($method = $reflection->getActionRenderMethod($action)) { + $this->validateLinkTarget($refPresenter, $method, "action '$presenter:$action'", $mode); + + UI\ParameterConverter::toParameters($method, $args, $path === 'this' ? $refPresenter->getParameters() : [], $missing); + + } elseif (array_key_exists(0, $args)) { + throw new UI\InvalidLinkException("Unable to pass parameters to action '$presenter:$action', missing corresponding method $presenterClass::{$presenterClass::formatRenderMethod($action)}()."); + } + + // counterpart of StatePersistent + if ($refPresenter) { + if (empty($signal) && $args && array_intersect_key($args, $reflection->getPersistentParams())) { + $refPresenter->saveStatePartial($args, $reflection); + } + + $globalState = $refPresenter->getGlobalState($path === 'this' ? null : $presenterClass); + if ($current && $args) { + $tmp = $globalState + $refPresenter->getParameters(); + foreach ($args as $key => $val) { + if (http_build_query([$val]) !== (isset($tmp[$key]) ? http_build_query([$tmp[$key]]) : '')) { + $current = false; + break; + } + } + } + + $args += $globalState; + } + } + + if ($mode !== 'test' && !empty($missing)) { + foreach ($missing as $rp) { + if (!array_key_exists($rp->getName(), $args)) { + throw new UI\InvalidLinkException("Missing parameter \${$rp->getName()} required by " . Reflection::toString($rp->getDeclaringFunction())); + } + } + } + + // ADD ACTION & SIGNAL & FLASH + if ($action) { + $args[UI\Presenter::ActionKey] = $action; + } + + if (!empty($signal)) { + $args[UI\Presenter::SignalKey] = $component->getParameterId($signal); + $current = $current && $args[UI\Presenter::SignalKey] === $refPresenter->getParameter(UI\Presenter::SignalKey); + } + + if (($mode === 'redirect' || $mode === 'forward') && $refPresenter->hasFlashSession()) { + $flashKey = $refPresenter->getParameter(UI\Presenter::FlashKey); + $args[UI\Presenter::FlashKey] = is_string($flashKey) && $flashKey !== '' ? $flashKey : null; + } + + return $this->lastRequest = new Request($presenter, Request::FORWARD, $args, flags: ['current' => $current]); + } + + + /** + * Parse destination in format "[//] [[[module:]presenter:]action | signal! | this | @alias] [?query] [#fragment]" + * @throws UI\InvalidLinkException + * @return array{absolute: bool, path: string, signal: bool, args: ?array, fragment: string} + * @internal + */ + public static function parseDestination(string $destination): array + { + if (!preg_match('~^ (?//)?+ (?[^!?#]++) (?!)?+ (?\?[^#]*)?+ (?\#.*)?+ $~x', $destination, $matches)) { + throw new UI\InvalidLinkException("Invalid destination '$destination'."); + } + + if (!empty($matches['query'])) { + trigger_error("Link format is obsolete, use arguments instead of query string in '$destination'.", E_USER_DEPRECATED); + parse_str(substr($matches['query'], 1), $args); + } + + return [ + 'absolute' => (bool) $matches['absolute'], + 'path' => $matches['path'], + 'signal' => !empty($matches['signal']), + 'args' => $args ?? null, + 'fragment' => $matches['fragment'] ?? '', + ]; + } + + + /** + * Converts Request to URL. + */ + public function requestToUrl(Request $request, bool $relative = false): string + { + $url = $this->router->constructUrl($request->toArray(), $this->refUrl); + if ($url === null) { + $params = $request->getParameters(); + unset($params[UI\Presenter::ActionKey], $params[UI\Presenter::PresenterKey]); + $params = urldecode(http_build_query($params, '', ', ')); + throw new UI\InvalidLinkException("No route for {$request->getPresenterName()}:{$request->getParameter('action')}($params)"); + } + + if ($relative) { + $hostUrl = $this->refUrl->getHostUrl() . '/'; + if (str_starts_with($url, $hostUrl)) { + $url = substr($url, strlen($hostUrl) - 1); + } + } + + return $url; + } + + + public function withReferenceUrl(string $url): static + { + return new static( + $this->router, + new UrlScript($url), + $this->presenterFactory, + ); + } + + + public function getLastRequest(): ?Request + { + return $this->lastRequest; + } + + + private function validateLinkTarget( + ?UI\Presenter $presenter, + \ReflectionClass|\ReflectionMethod $element, + string $message, + string $mode, + ): void + { + if ($mode !== 'forward' && !(new UI\AccessPolicy($element))->isLinkable()) { + throw new UI\InvalidLinkException("Link to forbidden $message from '{$presenter->getName()}:{$presenter->getAction()}'."); + } elseif ($presenter?->invalidLinkMode && $element->getAttributes(Attributes\Deprecated::class)) { + trigger_error("Link to deprecated $message from '{$presenter->getName()}:{$presenter->getAction()}'.", E_USER_DEPRECATED); + } + } + + + /** @internal */ + public static function applyBase(string $link, string $base): string + { + return str_contains($link, ':') && $link[0] !== ':' + ? ":$base:$link" + : $link; + } +} diff --git a/src/Application/LinkGenerator.php b/src/Application/LinkGenerator.php index efc3e6d88..c9f2ffb27 100644 --- a/src/Application/LinkGenerator.php +++ b/src/Application/LinkGenerator.php @@ -9,49 +9,23 @@ namespace Nette\Application; -use Nette\Http\UrlScript; -use Nette\Routing\Router; -use Nette\Utils\Reflection; -use function array_intersect_key, array_key_exists, http_build_query, is_string, is_subclass_of, parse_str, preg_match, rtrim, str_contains, str_ends_with, strcasecmp, strlen, strncmp, strtr, substr, trigger_error, urldecode; - /** - * Link generator. + * Generates links to presenter actions. */ -final class LinkGenerator +interface LinkGenerator { - /** @internal */ - public ?Request $lastRequest = null; - - - public function __construct( - private readonly Router $router, - private readonly UrlScript $refUrl, - private readonly ?IPresenterFactory $presenterFactory = null, - ) { - } - - /** - * Generates URL to presenter. + * Generates URL to presenter. Returns null when $mode is 'forward' or 'test'. * @param string $destination in format "[//] [[[module:]presenter:]action | signal! | this | @alias] [#fragment]" * @throws UI\InvalidLinkException */ - public function link( + function link( string $destination, array $args = [], ?UI\Component $component = null, ?string $mode = null, - ): ?string - { - $parts = self::parseDestination($destination); - $args = $parts['args'] ?? $args; - $request = $this->createRequest($component, $parts['path'] . ($parts['signal'] ? '!' : ''), $args, $mode ?? 'link'); - $relative = $mode === 'link' && !$parts['absolute'] && !$component?->getPresenter()->absoluteUrls; - return $mode === 'forward' || $mode === 'test' - ? null - : $this->requestToUrl($request, $relative) . $parts['fragment']; - } + ): ?string; /** @@ -60,259 +34,29 @@ public function link( * @throws UI\InvalidLinkException * @internal */ - public function createRequest( + function createRequest( ?UI\Component $component, string $destination, array $args, string $mode, - ): Request - { - // note: createRequest supposes that saveState(), run() & tryCall() behaviour is final - - $this->lastRequest = null; - $refPresenter = $component?->getPresenter(); - $path = $destination; - - if (($component && !$component instanceof UI\Presenter) || str_ends_with($destination, '!')) { - [$cname, $signal] = Helpers::splitName(rtrim($destination, '!')); - if ($cname !== '') { - $component = $component->getComponent(strtr($cname, ':', '-')); - } - - if ($signal === '') { - throw new UI\InvalidLinkException('Signal must be non-empty string.'); - } - - $path = 'this'; - } - - if ($path[0] === '@') { - if (!$this->presenterFactory instanceof PresenterFactory) { - throw new \LogicException('Link aliasing requires PresenterFactory service.'); - } - $path = ':' . $this->presenterFactory->getAlias(substr($path, 1)); - } - - $current = false; - [$presenter, $action] = Helpers::splitName($path); - if ($presenter === '') { - if (!$refPresenter) { - throw new \LogicException("Presenter must be specified in '$destination'."); - } - $action = $path === 'this' ? $refPresenter->getAction() : $action; - $presenter = $refPresenter->getName(); - $presenterClass = $refPresenter::class; - - } else { - if ($presenter[0] === ':') { // absolute - $presenter = substr($presenter, 1); - if (!$presenter) { - throw new UI\InvalidLinkException("Missing presenter name in '$destination'."); - } - } elseif ($refPresenter) { // relative - [$module, , $sep] = Helpers::splitName($refPresenter->getName()); - $presenter = $module . $sep . $presenter; - } - - try { - $presenterClass = $this->presenterFactory?->getPresenterClass($presenter); - } catch (InvalidPresenterException $e) { - throw new UI\InvalidLinkException($e->getMessage(), 0, $e); - } - } - - // PROCESS SIGNAL ARGUMENTS - if (isset($signal)) { // $component must be StatePersistent - $reflection = new UI\ComponentReflection($component::class); - if ($signal === 'this') { // means "no signal" - $signal = ''; - if (array_key_exists(0, $args)) { - throw new UI\InvalidLinkException("Unable to pass parameters to 'this!' signal."); - } - } elseif (!str_contains($signal, UI\Component::NameSeparator)) { - // counterpart of signalReceived() & tryCall() - - $method = $reflection->getSignalMethod($signal); - if (!$method) { - throw new UI\InvalidLinkException("Unknown signal '$signal', missing handler {$reflection->getName()}::{$component::formatSignalMethod($signal)}()"); - } - - $this->validateLinkTarget($refPresenter, $method, "signal '$signal'" . ($component === $refPresenter ? '' : ' in ' . $component::class), $mode); - - // convert indexed parameters to named - UI\ParameterConverter::toParameters($method, $args, [], $missing); - } - - // counterpart of StatePersistent - if ($args && array_intersect_key($args, $reflection->getPersistentParams())) { - $component->saveState($args); - } - - if ($args && $component !== $refPresenter) { - $prefix = $component->getUniqueId() . UI\Component::NameSeparator; - foreach ($args as $key => $val) { - unset($args[$key]); - $args[$prefix . $key] = $val; - } - } - } - - // PROCESS ARGUMENTS - if (is_subclass_of($presenterClass, UI\Presenter::class)) { - if ($action === '') { - $action = UI\Presenter::DefaultAction; - } - - $current = $refPresenter && ($action === '*' || strcasecmp($action, $refPresenter->getAction()) === 0) && $presenterClass === $refPresenter::class; - - $reflection = new UI\ComponentReflection($presenterClass); - $this->validateLinkTarget($refPresenter, $reflection, "presenter '$presenter'", $mode); - - foreach (array_intersect_key($reflection->getParameters(), $args) as $name => $param) { - if ($args[$name] === $param['def']) { - $args[$name] = null; // value transmit is unnecessary - } - } - - // counterpart of run() & tryCall() - if ($method = $reflection->getActionRenderMethod($action)) { - $this->validateLinkTarget($refPresenter, $method, "action '$presenter:$action'", $mode); - - UI\ParameterConverter::toParameters($method, $args, $path === 'this' ? $refPresenter->getParameters() : [], $missing); - - } elseif (array_key_exists(0, $args)) { - throw new UI\InvalidLinkException("Unable to pass parameters to action '$presenter:$action', missing corresponding method $presenterClass::{$presenterClass::formatRenderMethod($action)}()."); - } - - // counterpart of StatePersistent - if ($refPresenter) { - if (empty($signal) && $args && array_intersect_key($args, $reflection->getPersistentParams())) { - $refPresenter->saveStatePartial($args, $reflection); - } - - $globalState = $refPresenter->getGlobalState($path === 'this' ? null : $presenterClass); - if ($current && $args) { - $tmp = $globalState + $refPresenter->getParameters(); - foreach ($args as $key => $val) { - if (http_build_query([$val]) !== (isset($tmp[$key]) ? http_build_query([$tmp[$key]]) : '')) { - $current = false; - break; - } - } - } - - $args += $globalState; - } - } - - if ($mode !== 'test' && !empty($missing)) { - foreach ($missing as $rp) { - if (!array_key_exists($rp->getName(), $args)) { - throw new UI\InvalidLinkException("Missing parameter \${$rp->getName()} required by " . Reflection::toString($rp->getDeclaringFunction())); - } - } - } - - // ADD ACTION & SIGNAL & FLASH - if ($action) { - $args[UI\Presenter::ActionKey] = $action; - } - - if (!empty($signal)) { - $args[UI\Presenter::SignalKey] = $component->getParameterId($signal); - $current = $current && $args[UI\Presenter::SignalKey] === $refPresenter->getParameter(UI\Presenter::SignalKey); - } - - if (($mode === 'redirect' || $mode === 'forward') && $refPresenter->hasFlashSession()) { - $flashKey = $refPresenter->getParameter(UI\Presenter::FlashKey); - $args[UI\Presenter::FlashKey] = is_string($flashKey) && $flashKey !== '' ? $flashKey : null; - } - - return $this->lastRequest = new Request($presenter, Request::FORWARD, $args, flags: ['current' => $current]); - } + ): Request; /** - * Parse destination in format "[//] [[[module:]presenter:]action | signal! | this | @alias] [?query] [#fragment]" - * @throws UI\InvalidLinkException - * @return array{absolute: bool, path: string, signal: bool, args: ?array, fragment: string} - * @internal + * Converts Request to URL. */ - public static function parseDestination(string $destination): array - { - if (!preg_match('~^ (?//)?+ (?[^!?#]++) (?!)?+ (?\?[^#]*)?+ (?\#.*)?+ $~x', $destination, $matches)) { - throw new UI\InvalidLinkException("Invalid destination '$destination'."); - } - - if (!empty($matches['query'])) { - trigger_error("Link format is obsolete, use arguments instead of query string in '$destination'.", E_USER_DEPRECATED); - parse_str(substr($matches['query'], 1), $args); - } - - return [ - 'absolute' => (bool) $matches['absolute'], - 'path' => $matches['path'], - 'signal' => !empty($matches['signal']), - 'args' => $args ?? null, - 'fragment' => $matches['fragment'] ?? '', - ]; - } + function requestToUrl(Request $request, bool $relative = false): string; /** - * Converts Request to URL. + * Creates a new instance with a different reference URL. */ - public function requestToUrl(Request $request, ?bool $relative = false): string - { - $url = $this->router->constructUrl($request->toArray(), $this->refUrl); - if ($url === null) { - $params = $request->getParameters(); - unset($params[UI\Presenter::ActionKey], $params[UI\Presenter::PresenterKey]); - $params = urldecode(http_build_query($params, '', ', ')); - throw new UI\InvalidLinkException("No route for {$request->getPresenterName()}:{$request->getParameter('action')}($params)"); - } - - if ($relative) { - $hostUrl = $this->refUrl->getHostUrl() . '/'; - if (str_starts_with($url, $hostUrl)) { - $url = substr($url, strlen($hostUrl) - 1); - } - } - - return $url; - } - - - public function withReferenceUrl(string $url): static - { - return new self( - $this->router, - new UrlScript($url), - $this->presenterFactory, - ); - } + function withReferenceUrl(string $url): static; - private function validateLinkTarget( - ?UI\Presenter $presenter, - \ReflectionClass|\ReflectionMethod $element, - string $message, - string $mode, - ): void - { - if ($mode !== 'forward' && !(new UI\AccessPolicy($element))->isLinkable()) { - throw new UI\InvalidLinkException("Link to forbidden $message from '{$presenter->getName()}:{$presenter->getAction()}'."); - } elseif ($presenter?->invalidLinkMode && $element->getAttributes(Attributes\Deprecated::class)) { - trigger_error("Link to deprecated $message from '{$presenter->getName()}:{$presenter->getAction()}'.", E_USER_DEPRECATED); - } - } - - - /** @internal */ - public static function applyBase(string $link, string $base): string - { - return str_contains($link, ':') && $link[0] !== ':' - ? ":$base:$link" - : $link; - } + /** + * Returns the last created Request. + * @internal + */ + function getLastRequest(): ?Request; } diff --git a/src/Application/UI/Presenter.php b/src/Application/UI/Presenter.php index 32752f7f2..64c454448 100644 --- a/src/Application/UI/Presenter.php +++ b/src/Application/UI/Presenter.php @@ -11,6 +11,7 @@ use Nette; use Nette\Application; +use Nette\Application\DefaultLinkGenerator; use Nette\Application\Helpers; use Nette\Application\LinkGenerator; use Nette\Application\Responses; @@ -117,7 +118,7 @@ abstract class Presenter extends Control implements Application\IPresenter private readonly ?Nette\Http\Session $session; private readonly ?Nette\Security\User $user; private readonly ?TemplateFactory $templateFactory; - private readonly LinkGenerator $linkGenerator; + private ?LinkGenerator $linkGenerator = null; final public function getRequest(): ?Application\Request @@ -742,7 +743,7 @@ public function redirectUrl(string $url, ?int $httpCode = null): never */ final public function getLastCreatedRequest(): ?Application\Request { - return $this->linkGenerator->lastRequest; + return $this->linkGenerator->getLastRequest(); } @@ -752,7 +753,7 @@ final public function getLastCreatedRequest(): ?Application\Request */ final public function getLastCreatedRequestFlag(string $flag): bool { - return (bool) $this->linkGenerator->lastRequest?->hasFlag($flag); + return (bool) $this->linkGenerator->getLastRequest()?->hasFlag($flag); } @@ -827,7 +828,7 @@ protected function createRequest(Component $component, string $destination, arra public static function parseDestination(string $destination): array { trigger_error(__METHOD__ . '() is deprecated', E_USER_DEPRECATED); - return LinkGenerator::parseDestination($destination); + return DefaultLinkGenerator::parseDestination($destination); } @@ -1125,6 +1126,7 @@ final public function injectPrimary( ?Http\Session $session = null, ?Nette\Security\User $user = null, ?TemplateFactory $templateFactory = null, + ?LinkGenerator $linkGenerator = null, ): void { $this->httpRequest = $httpRequest; @@ -1132,9 +1134,11 @@ final public function injectPrimary( $this->session = $session; $this->user = $user; $this->templateFactory = $templateFactory; - if ($router && $presenterFactory) { + if ($linkGenerator) { + $this->linkGenerator = $linkGenerator; + } elseif ($router && $presenterFactory) { $url = $httpRequest->getUrl(); - $this->linkGenerator = new LinkGenerator( + $this->linkGenerator = new DefaultLinkGenerator( $router, new Http\UrlScript($url->getHostUrl() . $url->getScriptPath()), $presenterFactory, @@ -1179,7 +1183,7 @@ final public function getTemplateFactory(): TemplateFactory } - final protected function getLinkGenerator(): LinkGenerator + protected function getLinkGenerator(): LinkGenerator { return $this->linkGenerator ?? throw new Nette\InvalidStateException('Unable to create link to other presenter, service PresenterFactory or Router has not been set.'); } diff --git a/src/Bridges/ApplicationDI/ApplicationExtension.php b/src/Bridges/ApplicationDI/ApplicationExtension.php index 8dca9c25e..c3fa8d9ac 100644 --- a/src/Bridges/ApplicationDI/ApplicationExtension.php +++ b/src/Bridges/ApplicationDI/ApplicationExtension.php @@ -12,6 +12,8 @@ use Composer\Autoload\ClassLoader; use Nette; use Nette\Application\Attributes; +use Nette\Application\DefaultLinkGenerator; +use Nette\Application\LinkGenerator; use Nette\Application\UI; use Nette\DI\Definitions; use Nette\Schema\Expect; @@ -114,7 +116,8 @@ public function loadConfiguration(): void } $builder->addDefinition($this->prefix('linkGenerator')) - ->setFactory(Nette\Application\LinkGenerator::class, [ + ->setType(LinkGenerator::class) + ->setFactory(DefaultLinkGenerator::class, [ 1 => new Definitions\Statement([new Definitions\Statement('@Nette\Http\IRequest::getUrl'), 'withoutUserInfo']), ]); diff --git a/src/Bridges/ApplicationLatte/Nodes/LinkBaseNode.php b/src/Bridges/ApplicationLatte/Nodes/LinkBaseNode.php index 1956348be..81dcd628f 100644 --- a/src/Bridges/ApplicationLatte/Nodes/LinkBaseNode.php +++ b/src/Bridges/ApplicationLatte/Nodes/LinkBaseNode.php @@ -20,7 +20,7 @@ use Latte\Compiler\NodeTraverser; use Latte\Compiler\PrintContext; use Latte\Compiler\Tag; -use Nette\Application\LinkGenerator; +use Nette\Application\DefaultLinkGenerator; /** @@ -66,12 +66,12 @@ public static function applyLinkBasePass(TemplateNode $node): void (new NodeTraverser)->traverse($node, function (Node $link) use ($base) { if ($link instanceof LinkNode) { if ($link->destination instanceof StringNode && $base instanceof StringNode) { - $link->destination->value = LinkGenerator::applyBase($link->destination->value, $base->value); + $link->destination->value = DefaultLinkGenerator::applyBase($link->destination->value, $base->value); } else { $origDestination = $link->destination; $link->destination = new AuxiliaryNode( fn(PrintContext $context) => $context->format( - LinkGenerator::class . '::applyBase(%node, %node)', + DefaultLinkGenerator::class . '::applyBase(%node, %node)', $origDestination, $base, ), diff --git a/tests/Bridges.DI/ApplicationExtension.basic.phpt b/tests/Bridges.DI/ApplicationExtension.basic.phpt index 7a0351ca8..49ccff1f7 100644 --- a/tests/Bridges.DI/ApplicationExtension.basic.phpt +++ b/tests/Bridges.DI/ApplicationExtension.basic.phpt @@ -25,5 +25,6 @@ test('', function () { $container = new Container1; Assert::type(Nette\Application\Application::class, $container->getService('application')); Assert::type(Nette\Application\PresenterFactory::class, $container->getService('nette.presenterFactory')); - Assert::type(Nette\Application\LinkGenerator::class, $container->getService('application.linkGenerator')); + Assert::type(Nette\Application\DefaultLinkGenerator::class, $container->getService('application.linkGenerator')); + Assert::type(Nette\Application\LinkGenerator::class, $container->getByType(Nette\Application\LinkGenerator::class)); }); diff --git a/tests/Bridges.Latte/{linkBase}.phpt b/tests/Bridges.Latte/{linkBase}.phpt index 78d6f66cf..a700d5ae6 100644 --- a/tests/Bridges.Latte/{linkBase}.phpt +++ b/tests/Bridges.Latte/{linkBase}.phpt @@ -32,10 +32,10 @@ Assert::contains( // dynamic Assert::contains( - '$this->global->uiControl->link(Nette\Application\LinkGenerator::applyBase($link, \'Base\'))', + '$this->global->uiControl->link(Nette\Application\DefaultLinkGenerator::applyBase($link, \'Base\'))', $latte->compile('{linkBase Base}{link $link}'), ); Assert::contains( - '$this->global->uiControl->link(Nette\Application\LinkGenerator::applyBase(\'foo\', $base))', + '$this->global->uiControl->link(Nette\Application\DefaultLinkGenerator::applyBase(\'foo\', $base))', $latte->compile('{linkBase $base}{link foo}'), ); diff --git a/tests/Routers/LinkGenerator.phpt b/tests/Routers/LinkGenerator.phpt index d211d9c14..ec8383216 100644 --- a/tests/Routers/LinkGenerator.phpt +++ b/tests/Routers/LinkGenerator.phpt @@ -41,7 +41,7 @@ namespace App\Presentation\Module\My { namespace { - use Nette\Application\LinkGenerator; + use Nette\Application\DefaultLinkGenerator; use Nette\Application\PresenterFactory; use Nette\Application\Routers; use Nette\Http; @@ -52,7 +52,7 @@ namespace { test('basic link generation with various parameters', function () use ($pf) { - $generator = new LinkGenerator(new Routers\SimpleRouter, new Http\UrlScript('http://nette.org/en/'), $pf); + $generator = new DefaultLinkGenerator(new Routers\SimpleRouter, new Http\UrlScript('http://nette.org/en/'), $pf); Assert::same('http://nette.org/en/?action=default&presenter=Homepage', $generator->link('Homepage:default')); Assert::same('http://nette.org/en/?action=default&presenter=Module%3AMy', $generator->link('Module:My:default')); Assert::same('http://nette.org/en/?presenter=Module%3AMy', $generator->link('Module:My:')); @@ -65,25 +65,25 @@ namespace { testException('missing presenter specification error', function () use ($pf) { - $generator = new LinkGenerator(new Routers\SimpleRouter, new Http\UrlScript('http://nette.org/en/'), $pf); + $generator = new DefaultLinkGenerator(new Routers\SimpleRouter, new Http\UrlScript('http://nette.org/en/'), $pf); $generator->link('default'); }, LogicException::class, "Presenter must be specified in 'default'."); testException('route mismatch exception handling', function () use ($pf) { - $generator = new LinkGenerator(new Routers\Route('/', 'Product:'), new Http\UrlScript('http://nette.org/en/'), $pf); + $generator = new DefaultLinkGenerator(new Routers\Route('/', 'Product:'), new Http\UrlScript('http://nette.org/en/'), $pf); $generator->link('Homepage:default', ['id' => 10]); }, Nette\Application\UI\InvalidLinkException::class, 'No route for Homepage:default(id=10)'); testException('invalid action parameter propagation', function () use ($pf) { - $generator = new LinkGenerator(new Routers\Route('/', 'Homepage:'), new Http\UrlScript('http://nette.org/en/'), $pf); + $generator = new DefaultLinkGenerator(new Routers\Route('/', 'Homepage:'), new Http\UrlScript('http://nette.org/en/'), $pf); $generator->link('Homepage:missing', [10]); }, Nette\Application\UI\InvalidLinkException::class, "Unable to pass parameters to action 'Homepage:missing', missing corresponding method App\\Presentation\\Homepage\\HomepagePresenter::renderMissing()."); test('URL generation without PresenterFactory', function () { - $generator = new LinkGenerator(new Routers\SimpleRouter, new Http\UrlScript('http://nette.org/en/')); + $generator = new DefaultLinkGenerator(new Routers\SimpleRouter, new Http\UrlScript('http://nette.org/en/')); Assert::same('http://nette.org/en/?action=default&presenter=Homepage', $generator->link('Homepage:default')); Assert::same('http://nette.org/en/?action=default&presenter=Module%3AMy', $generator->link('Module:My:default')); Assert::same('http://nette.org/en/?presenter=Module%3AMy', $generator->link('Module:My:')); @@ -96,7 +96,7 @@ namespace { test('reference URL context switching', function () { - $generator = new LinkGenerator(new Routers\SimpleRouter, new Http\UrlScript('http://nette.org/en/')); + $generator = new DefaultLinkGenerator(new Routers\SimpleRouter, new Http\UrlScript('http://nette.org/en/')); $generator2 = $generator->withReferenceUrl('http://nette.org/cs/'); Assert::same('http://nette.org/en/?action=default&presenter=Homepage', $generator->link('Homepage:default')); Assert::same('http://nette.org/cs/?action=default&presenter=Homepage', $generator2->link('Homepage:default')); diff --git a/tests/Routers/link-aliases.phpt b/tests/Routers/link-aliases.phpt index 2180bd526..eb4222338 100644 --- a/tests/Routers/link-aliases.phpt +++ b/tests/Routers/link-aliases.phpt @@ -37,7 +37,7 @@ Assert::exception( // link generator -$generator = new Application\LinkGenerator( +$generator = new Application\DefaultLinkGenerator( new Application\Routers\SimpleRouter, new Http\UrlScript('http://localhost'), $factory, diff --git a/tests/UI/LinkGenerator.decorator.phpt b/tests/UI/LinkGenerator.decorator.phpt new file mode 100644 index 000000000..427caa9e2 --- /dev/null +++ b/tests/UI/LinkGenerator.decorator.phpt @@ -0,0 +1,195 @@ +linkCallCount++; + + return $this->inner->link($destination, $args, $component, $mode); + } + + + public function createRequest( + ?Component $component, + string $destination, + array $args, + string $mode, + ): Request + { + return $this->inner->createRequest($component, $destination, $args, $mode); + } + + + public function requestToUrl(Request $request, bool $relative = false): string + { + return $this->inner->requestToUrl($request, $relative); + } + + + public function withReferenceUrl(string $url): static + { + return new static($this->inner->withReferenceUrl($url)); + } + + + public function getLastRequest(): ?Request + { + return $this->inner->getLastRequest(); + } + } + + + /** + * A decorator that modifies generated URLs by adding a prefix. + */ + class PrefixingLinkGenerator implements LinkGenerator + { + public function __construct( + private readonly LinkGenerator $inner, + private readonly string $prefix, + ) { + } + + + public function link( + string $destination, + array $args = [], + ?Component $component = null, + ?string $mode = null, + ): ?string + { + $url = $this->inner->link($destination, $args, $component, $mode); + + return $url !== null ? $this->prefix . $url : null; + } + + + public function createRequest( + ?Component $component, + string $destination, + array $args, + string $mode, + ): Request + { + return $this->inner->createRequest($component, $destination, $args, $mode); + } + + + public function requestToUrl(Request $request, bool $relative = false): string + { + return $this->inner->requestToUrl($request, $relative); + } + + + public function withReferenceUrl(string $url): static + { + return new static($this->inner->withReferenceUrl($url), $this->prefix); + } + + + public function getLastRequest(): ?Request + { + return $this->inner->getLastRequest(); + } + } + + + test('injected decorator is used by Presenter', function () { + $inner = new Nette\Application\DefaultLinkGenerator( + new Nette\Application\Routers\SimpleRouter, + new Nette\Http\UrlScript('http://nette.org/en/'), + ); + $decorator = new TrackingLinkGenerator($inner); + + $presenter = new App\Presentation\Homepage\HomepagePresenter; + $presenter->injectPrimary( + httpRequest: new Nette\Http\Request(new Nette\Http\UrlScript('http://nette.org/en/')), + httpResponse: new Nette\Http\Response, + linkGenerator: $decorator, + ); + + Assert::same(0, $decorator->linkCallCount); + $presenter->link(':Homepage:default'); + Assert::same(1, $decorator->linkCallCount); + }); + + + test('decorator can modify generated URLs', function () { + $inner = new Nette\Application\DefaultLinkGenerator( + new Nette\Application\Routers\SimpleRouter, + new Nette\Http\UrlScript('http://nette.org/en/'), + ); + $decorator = new PrefixingLinkGenerator($inner, '/proxy'); + + $originalUrl = $inner->link('Homepage:default'); + $decoratedUrl = $decorator->link('Homepage:default'); + + Assert::same('/proxy' . $originalUrl, $decoratedUrl); + }); + + + test('decorator preserves null return for test mode', function () { + $inner = new Nette\Application\DefaultLinkGenerator( + new Nette\Application\Routers\SimpleRouter, + new Nette\Http\UrlScript('http://nette.org/en/'), + ); + $decorator = new PrefixingLinkGenerator($inner, '/proxy'); + + Assert::null($decorator->link('Homepage:default', [], null, 'test')); + }); + + + test('Presenter::getLinkGenerator() is not final', function () { + Assert::false((new ReflectionMethod(Nette\Application\UI\Presenter::class, 'getLinkGenerator'))->isFinal()); + }); + +} diff --git a/tests/UI/LinkGenerator.interface.phpt b/tests/UI/LinkGenerator.interface.phpt new file mode 100644 index 000000000..1061cc9f1 --- /dev/null +++ b/tests/UI/LinkGenerator.interface.phpt @@ -0,0 +1,77 @@ +getLastRequest()); +}); + + +test('getLastRequest() returns Request after link()', function () { + $generator = new DefaultLinkGenerator(new Routers\SimpleRouter, new Http\UrlScript('http://nette.org/en/')); + $generator->link('Homepage:default'); + Assert::type(Request::class, $generator->getLastRequest()); +}); + + +test('getLastRequest() returns Request even when requestToUrl() fails', function () { + $generator = new DefaultLinkGenerator(new Routers\Route('/', 'Product:'), new Http\UrlScript('http://nette.org/en/')); + + Assert::exception( + fn() => $generator->link('Homepage:default', ['id' => 10]), + Nette\Application\UI\InvalidLinkException::class, + ); + + Assert::type(Request::class, $generator->getLastRequest()); +}); + + +test('withReferenceUrl() returns a new instance', function () { + $generator = new DefaultLinkGenerator(new Routers\SimpleRouter, new Http\UrlScript('http://nette.org/en/')); + $generator2 = $generator->withReferenceUrl('http://nette.org/cs/'); + Assert::type(LinkGenerator::class, $generator2); + Assert::notSame($generator, $generator2); +}); + + +test('withReferenceUrl() new instance uses the new reference URL', function () { + $generator = new DefaultLinkGenerator(new Routers\SimpleRouter, new Http\UrlScript('http://nette.org/en/')); + $generator2 = $generator->withReferenceUrl('http://nette.org/cs/'); + Assert::contains('nette.org/cs/', $generator2->link('Homepage:default')); +}); + + +test('createRequest() returns a Request', function () { + $generator = new DefaultLinkGenerator(new Routers\SimpleRouter, new Http\UrlScript('http://nette.org/en/')); + $request = $generator->createRequest(null, 'Homepage:default', [], 'link'); + Assert::type(Request::class, $request); +}); + + +test('requestToUrl() converts Request to URL string', function () { + $generator = new DefaultLinkGenerator(new Routers\SimpleRouter, new Http\UrlScript('http://nette.org/en/')); + $request = $generator->createRequest(null, 'Homepage:default', [], 'link'); + Assert::type('string', $generator->requestToUrl($request)); +}); diff --git a/tests/UI/LinkGenerator.parseDestination().phpt b/tests/UI/LinkGenerator.parseDestination().phpt index 82655a85b..1cde94d54 100644 --- a/tests/UI/LinkGenerator.parseDestination().phpt +++ b/tests/UI/LinkGenerator.parseDestination().phpt @@ -2,7 +2,7 @@ declare(strict_types=1); -use Nette\Application\LinkGenerator; +use Nette\Application\DefaultLinkGenerator; use Nette\Application\UI\InvalidLinkException; use Tester\Assert; @@ -16,7 +16,7 @@ Assert::same([ 'signal' => false, 'args' => null, 'fragment' => '', -], LinkGenerator::parseDestination('a:b')); +], DefaultLinkGenerator::parseDestination('a:b')); Assert::same([ 'absolute' => false, @@ -24,7 +24,7 @@ Assert::same([ 'signal' => true, 'args' => null, 'fragment' => '', -], LinkGenerator::parseDestination('a:b!')); +], DefaultLinkGenerator::parseDestination('a:b!')); Assert::same([ 'absolute' => true, @@ -32,7 +32,7 @@ Assert::same([ 'signal' => false, 'args' => null, 'fragment' => '', -], LinkGenerator::parseDestination('//a:b')); +], DefaultLinkGenerator::parseDestination('//a:b')); Assert::same([ 'absolute' => false, @@ -40,7 +40,7 @@ Assert::same([ 'signal' => false, 'args' => null, 'fragment' => '#fragment', -], LinkGenerator::parseDestination('a:b#fragment')); +], DefaultLinkGenerator::parseDestination('a:b#fragment')); Assert::same([ 'absolute' => false, @@ -48,7 +48,7 @@ Assert::same([ 'signal' => false, 'args' => [], 'fragment' => '#fragment', -], @LinkGenerator::parseDestination('a:b?#fragment')); // deprecated +], @DefaultLinkGenerator::parseDestination('a:b?#fragment')); // deprecated Assert::same([ 'absolute' => false, @@ -56,9 +56,9 @@ Assert::same([ 'signal' => false, 'args' => ['a' => 'b', 'c' => 'd'], 'fragment' => '#fragment', -], @LinkGenerator::parseDestination('a:b?a=b&c=d#fragment')); // deprecated +], @DefaultLinkGenerator::parseDestination('a:b?a=b&c=d#fragment')); // deprecated Assert::exception( - fn() => LinkGenerator::parseDestination(''), + fn() => DefaultLinkGenerator::parseDestination(''), InvalidLinkException::class, );