Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
324 changes: 324 additions & 0 deletions src/Application/DefaultLinkGenerator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,324 @@
<?php

/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/

declare(strict_types=1);

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, str_starts_with, strcasecmp, strlen, strncmp, strtr, substr, trigger_error, urldecode;


/**
* Default link generator.
*/
class DefaultLinkGenerator implements 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.
* @param string $destination in format "[//] [[[module:]presenter:]action | signal! | this | @alias] [#fragment]"
* @throws UI\InvalidLinkException
*/
public 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'];
}


/**
* @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('~^ (?<absolute>//)?+ (?<path>[^!?#]++) (?<signal>!)?+ (?<query>\?[^#]*)?+ (?<fragment>\#.*)?+ $~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;
}
}
Loading