From 334434f9de093b2344bb4a294f0d81e1c18db56f Mon Sep 17 00:00:00 2001 From: Jules Pietri Date: Sun, 9 Feb 2020 13:53:06 +0100 Subject: [PATCH] [FrameworkBundle][Routing] added XML and YAML loaders to handle template and redirect controllers --- .../Bundle/FrameworkBundle/CHANGELOG.md | 3 +- .../Resources/config/routing.xml | 4 +- .../config/schema/framework-routing-1.0.xsd | 101 +++++++++ .../Routing/Loader/XmlFileLoader.php | 198 ++++++++++++++++++ .../Routing/Loader/YamlFileLoader.php | 103 +++++++++ .../Resources/config/routing/routes.xml | 54 +++++ .../Resources/config/routing/routes.yaml | 47 +++++ .../config/routing/template_and_redirect.yaml | 4 + .../routing/with_controller_attribute.yaml | 4 + .../Routing/Loader/XmlFileLoaderTest.php | 28 +++ .../Routing/Loader/YamlFileLoaderTest.php | 49 +++++ .../Routing/Loader/XmlFileLoader.php | 8 +- 12 files changed, 596 insertions(+), 7 deletions(-) create mode 100644 src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/framework-routing-1.0.xsd create mode 100644 src/Symfony/Bundle/FrameworkBundle/Routing/Loader/XmlFileLoader.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Routing/Loader/YamlFileLoader.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Resources/config/routing/routes.xml create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Resources/config/routing/routes.yaml create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Resources/config/routing/template_and_redirect.yaml create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Resources/config/routing/with_controller_attribute.yaml create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Routing/Loader/XmlFileLoaderTest.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Routing/Loader/YamlFileLoaderTest.php diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index fe887ca9c97bd..c28d8f255d36e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md @@ -6,7 +6,8 @@ CHANGELOG * Added link to source for controllers registered as named services * Added link to source on controller on `router:match`/`debug:router` (when `framework.ide` is configured) - * Added `Routing\Loader` and `Routing\Loader\Configurator` namespaces to ease defining routes with default controllers + * Added XML and YAML routing loaders to ease defining routes with redirect and template controllers + * Added the `Routing\Loader\Configurator` namespace to ease defining routes with redirect and template controllers * Added the `framework.router.context` configuration node to configure the `RequestContext` * Made `MicroKernelTrait::configureContainer()` compatible with `ContainerConfigurator` * Added a new `mailer.message_bus` option to configure or disable the message bus to use to send mails. diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/routing.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/routing.xml index e4105a59f4626..f7951377e228c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/routing.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/routing.xml @@ -15,12 +15,12 @@ - + - + diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/framework-routing-1.0.xsd b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/framework-routing-1.0.xsd new file mode 100644 index 0000000000000..43d71756627a3 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/framework-routing-1.0.xsd @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Routing/Loader/XmlFileLoader.php b/src/Symfony/Bundle/FrameworkBundle/Routing/Loader/XmlFileLoader.php new file mode 100644 index 0000000000000..91b0a1e4b432f --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Routing/Loader/XmlFileLoader.php @@ -0,0 +1,198 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Routing\Loader; + +use Symfony\Bundle\FrameworkBundle\Controller\RedirectController; +use Symfony\Bundle\FrameworkBundle\Controller\TemplateController; +use Symfony\Component\Config\Util\Exception\InvalidXmlException; +use Symfony\Component\Config\Util\Exception\XmlParsingException; +use Symfony\Component\Config\Util\XmlUtils; +use Symfony\Component\Routing\Loader\XmlFileLoader as BaseXmlFileLoader; +use Symfony\Component\Routing\Route; +use Symfony\Component\Routing\RouteCollection; + +/** + * @author Jules Pietri + */ +class XmlFileLoader extends BaseXmlFileLoader +{ + public const SCHEME_PATH = __DIR__.'/../../Resources/config/schema/framework-routing-1.0.xsd'; + + private const REDEFINED_SCHEME_URI = 'https://symfony.com/schema/routing/routing-1.0.xsd'; + private const SCHEME_URI = 'https://symfony.com/schema/routing/framework-routing-1.0.xsd'; + private const SCHEMA_LOCATIONS = [ + self::REDEFINED_SCHEME_URI => parent::SCHEME_PATH, + self::SCHEME_URI => self::SCHEME_PATH, + ]; + + /** @var \DOMDocument */ + private $document; + + /** + * {@inheritdoc} + */ + protected function loadFile(string $file) + { + if ('' === trim($content = @file_get_contents($file))) { + throw new \InvalidArgumentException(sprintf('File "%s" does not contain valid XML, it is empty.', $file)); + } + + foreach (self::SCHEMA_LOCATIONS as $uri => $path) { + if (false !== strpos($content, $uri)) { + $content = str_replace($uri, self::getRealSchemePath($path), $content); + } + } + + try { + return $this->document = XmlUtils::parse($content, function (\DOMDocument $document) { + return @$document->schemaValidateSource(str_replace( + self::REDEFINED_SCHEME_URI, + self::getRealSchemePath(parent::SCHEME_PATH), + file_get_contents(self::SCHEME_PATH) + )); + }); + } catch (InvalidXmlException $e) { + throw new XmlParsingException(sprintf('The XML file "%s" is not valid.', $file), 0, $e->getPrevious()); + } + } + + /** + * {@inheritdoc} + */ + protected function parseNode(RouteCollection $collection, \DOMElement $node, $path, $file) + { + switch ($node->localName) { + case 'template-route': + case 'redirect-route': + case 'url-redirect-route': + case 'gone-route': + if (self::NAMESPACE_URI !== $node->namespaceURI) { + return; + } + + $this->parseRoute($collection, $node, $path); + + return; + } + + parent::parseNode($collection, $node, $path, $file); + } + + /** + * {@inheritdoc} + */ + protected function parseRoute(RouteCollection $collection, \DOMElement $node, $path) + { + $templateContext = []; + + if ('template-route' === $node->localName) { + /** @var \DOMElement $context */ + foreach ($node->getElementsByTagNameNS(self::NAMESPACE_URI, 'context') as $context) { + $node->removeChild($context); + $map = $this->document->createElementNS(self::NAMESPACE_URI, 'map'); + + // extract context vars into a map + foreach ($context->childNodes as $n) { + if (!$n instanceof \DOMElement) { + continue; + } + + $map->appendChild($n); + } + + $default = $this->document->createElementNS(self::NAMESPACE_URI, 'default'); + $default->setAttribute('key', 'context'); + $default->appendChild($map); + + $templateContext = $this->parseDefaultsConfig($default, $path); + } + } + + parent::parseRoute($collection, $node, $path); + + if ($route = $collection->get($id = $node->getAttribute(('id')))) { + $this->parseConfig($node, $route, $templateContext); + + return; + } + + foreach ($node->getElementsByTagNameNS(self::NAMESPACE_URI, 'path') as $n) { + $route = $collection->get($id.'.'.$n->getAttribute('locale')); + + $this->parseConfig($node, $route, $templateContext); + } + } + + private function parseConfig(\DOMElement $node, Route $route, array $templateContext): void + { + switch ($node->localName) { + case 'template-route': + $route + ->setDefault('_controller', TemplateController::class) + ->setDefault('template', $node->getAttribute('template')) + ->setDefault('context', $templateContext) + ->setDefault('maxAge', (int) $node->getAttribute('max-age') ?: null) + ->setDefault('sharedAge', (int) $node->getAttribute('shared-max-age') ?: null) + ->setDefault('private', $node->hasAttribute('private') ? XmlUtils::phpize($node->getAttribute('private')) : null) + ; + break; + case 'redirect-route': + $route + ->setDefault('_controller', RedirectController::class.'::redirectAction') + ->setDefault('route', $node->getAttribute('redirect-to-route')) + ->setDefault('permanent', self::getBooleanAttribute($node, 'permanent')) + ->setDefault('keepRequestMethod', self::getBooleanAttribute($node, 'keep-request-method')) + ->setDefault('keepQueryParams', self::getBooleanAttribute($node, 'keep-query-params')) + ; + + if (\is_string($ignoreAttributes = XmlUtils::phpize($node->getAttribute('ignore-attributes')))) { + $ignoreAttributes = array_map('trim', explode(',', $ignoreAttributes)); + } + + $route->setDefault('ignoreAttributes', $ignoreAttributes); + break; + case 'url-redirect-route': + $route + ->setDefault('_controller', RedirectController::class.'::urlRedirectAction') + ->setDefault('path', $node->getAttribute('redirect-to-url')) + ->setDefault('permanent', self::getBooleanAttribute($node, 'permanent')) + ->setDefault('scheme', $node->getAttribute('scheme')) + ->setDefault('keepRequestMethod', self::getBooleanAttribute($node, 'keep-request-method')) + ; + if ($node->hasAttribute('http-port')) { + $route->setDefault('httpPort', (int) $node->getAttribute('http-port') ?: null); + } elseif ($node->hasAttribute('https-port')) { + $route->setDefault('httpsPort', (int) $node->getAttribute('https-port') ?: null); + } + break; + case 'gone-route': + $route + ->setDefault('_controller', RedirectController::class.'::redirectAction') + ->setDefault('route', '') + ; + if ($node->hasAttribute('permanent')) { + $route->setDefault('permanent', self::getBooleanAttribute($node, 'permanent')); + } + break; + } + } + + private static function getRealSchemePath(string $schemePath): string + { + return 'file:///'.str_replace('\\', '/', realpath($schemePath)); + } + + private static function getBooleanAttribute(\DOMElement $node, string $attribute): bool + { + return $node->hasAttribute($attribute) ? XmlUtils::phpize($node->getAttribute($attribute)) : false; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Routing/Loader/YamlFileLoader.php b/src/Symfony/Bundle/FrameworkBundle/Routing/Loader/YamlFileLoader.php new file mode 100644 index 0000000000000..ac819897ad4ad --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Routing/Loader/YamlFileLoader.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Routing\Loader; + +use Symfony\Bundle\FrameworkBundle\Controller\RedirectController; +use Symfony\Bundle\FrameworkBundle\Controller\TemplateController; +use Symfony\Component\Routing\Loader\YamlFileLoader as BaseYamlFileLoader; +use Symfony\Component\Routing\RouteCollection; + +/** + * @author Jules Pietri + */ +class YamlFileLoader extends BaseYamlFileLoader +{ + private static $availableKeys = [ + 'template' => ['context', 'max_age', 'shared_max_age', 'private'], + 'redirect_to_route' => ['permanent', 'ignore_attributes', 'keep_request_method', 'keep_query_params'], + 'redirect_to_url' => ['permanent', 'scheme', 'http_port', 'https_port', 'keep_request_method'], + 'gone' => ['permanent'], + ]; + + protected function validate($config, $name, $path) + { + if (\count($types = array_intersect_key($config, self::$availableKeys)) > 1) { + throw new \InvalidArgumentException(sprintf('The routing file "%s" must not specify only one route type among "%s" keys for "%s".', str_replace('/', \DIRECTORY_SEPARATOR, $path), implode('", "', array_keys($types)), $name)); + } + + foreach (self::$availableKeys as $routeType => $availableKeys) { + if (!isset($config[$routeType])) { + continue; + } + + if (isset($config['controller'])) { + throw new \InvalidArgumentException(sprintf('The routing file "%s" must not specify both the "controller" and the "%s" keys for "%s".', str_replace('/', \DIRECTORY_SEPARATOR, $path), $routeType, $name)); + } + + // keys would be invalid for parent::validate(), but we use them below + unset($config[$routeType]); + foreach ($availableKeys as $key) { + unset($config[$key]); + } + } + + parent::validate($config, $name, $path); + } + + protected function parseRoute(RouteCollection $collection, $name, array $config, $path) + { + if (isset($config['template'])) { + $config['defaults'] = array_merge($config['defaults'] ?? [], [ + '_controller' => TemplateController::class, + 'template' => $config['template'], + 'context' => $config['context'] ?? [], + 'maxAge' => $config['max_age'] ?? null, + 'sharedAge' => $config['shared_max_age'] ?? null, + 'private' => $config['private'] ?? null, + ]); + } elseif (isset($config['redirect_to_route'])) { + $config['defaults'] = array_merge($config['defaults'] ?? [], [ + '_controller' => RedirectController::class.'::redirectAction', + 'route' => $config['redirect_to_route'], + 'permanent' => $config['permanent'] ?? false, + 'ignoreAttributes' => $config['ignore_attributes'] ?? false, + 'keepRequestMethod' => $config['keep_request_method'] ?? false, + 'keepQueryParams' => $config['keep_query_params'] ?? false, + ]); + } elseif (isset($config['redirect_to_url'])) { + $config['defaults'] = array_merge($config['defaults'] ?? [], [ + '_controller' => RedirectController::class.'::urlRedirectAction', + 'path' => $config['redirect_to_url'], + 'permanent' => $config['permanent'] ?? false, + 'scheme' => $config['scheme'] ?? null, + 'keepRequestMethod' => $config['keep_request_method'] ?? false, + ]); + + if (\array_key_exists('http_port', $config)) { + $config['defaults']['httpPort'] = (int) $config['http_port'] ?: null; + } elseif (\array_key_exists('http_port', $config)) { + $config['defaults']['httpsPort'] = (int) $config['https_port'] ?: null; + } + } elseif (isset($config['gone'])) { + $config['defaults'] = array_merge($config['defaults'] ?? [], [ + '_controller' => RedirectController::class.'::redirectAction', + 'route' => '', + ]); + + if (isset($config['permanent'])) { + $config['defaults']['permanent'] = $config['permanent']; + } + } + + parent::parseRoute($collection, $name, $config, $path); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Resources/config/routing/routes.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Resources/config/routing/routes.xml new file mode 100644 index 0000000000000..6392fc5d4a08f --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Resources/config/routing/routes.xml @@ -0,0 +1,54 @@ + + + + + + + + bar + + + abc + + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Resources/config/routing/routes.yaml b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Resources/config/routing/routes.yaml new file mode 100644 index 0000000000000..6369855bc57b0 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Resources/config/routing/routes.yaml @@ -0,0 +1,47 @@ +classic_route: + path: /classic + +template_route: + path: /static + template: static.html.twig + context: + foo: bar + max_age: 300 + shared_max_age: 100 + private: true + methods: GET + options: { utf8: true } + condition: abc + +redirect_route: + path: /redirect + redirect_to_route: target_route + permanent: true + ignore_attributes: ['attr', 'ibutes'] + keep_request_method: true + keep_query_params: true + schemes: http + host: legacy + options: { utf8: true } + +url_redirect_route: + path: /redirect-url + redirect_to_url: /url-target + permanent: true + scheme: http + http_port: 1 + keep_request_method: true + host: legacy + options: { utf8: true } + +not_a_route: + path: /not-a-path + gone: true + host: legacy + options: { utf8: true } + +gone_route: + path: /gone-path + gone: true + permanent: true + options: { utf8: true } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Resources/config/routing/template_and_redirect.yaml b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Resources/config/routing/template_and_redirect.yaml new file mode 100644 index 0000000000000..50c2300ee698a --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Resources/config/routing/template_and_redirect.yaml @@ -0,0 +1,4 @@ +invalid_route: + path: '/path' + template: 'template.html.twig' + redirect_to_route: 'target_route' diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Resources/config/routing/with_controller_attribute.yaml b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Resources/config/routing/with_controller_attribute.yaml new file mode 100644 index 0000000000000..7dc728d5571b4 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Resources/config/routing/with_controller_attribute.yaml @@ -0,0 +1,4 @@ +invalid_route: + path: '/path' + template: 'template.html.twig' + controller: 'SomeControllerClass' diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Routing/Loader/XmlFileLoaderTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Routing/Loader/XmlFileLoaderTest.php new file mode 100644 index 0000000000000..91a28b0aeb355 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Routing/Loader/XmlFileLoaderTest.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Routing\Loader; + +use Symfony\Bundle\FrameworkBundle\Routing\Loader\XmlFileLoader; +use Symfony\Component\Config\Loader\LoaderInterface; + +class XmlFileLoaderTest extends AbstractLoaderTest +{ + protected function getLoader(): LoaderInterface + { + return new XmlFileLoader($this->getLocator()); + } + + protected function getType(): string + { + return 'xml'; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Routing/Loader/YamlFileLoaderTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Routing/Loader/YamlFileLoaderTest.php new file mode 100644 index 0000000000000..0e1a62643e704 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Routing/Loader/YamlFileLoaderTest.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Routing\Loader; + +use Symfony\Bundle\FrameworkBundle\Routing\Loader\YamlFileLoader; +use Symfony\Component\Config\Loader\LoaderInterface; + +class YamlFileLoaderTest extends AbstractLoaderTest +{ + protected function getLoader(): LoaderInterface + { + return new YamlFileLoader($this->getLocator()); + } + + protected function getType(): string + { + return 'yaml'; + } + + /** + * @dataProvider getPathsToInvalidFiles + */ + public function testLoadThrowsExceptionWithInvalidFile(string $filePath, string $exception) + { + $loader = $this->getLoader(); + + $message = sprintf($exception, __DIR__.'/../../Fixtures/Resources/config/routing/'.$filePath); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(str_replace('/', \DIRECTORY_SEPARATOR, $message)); + + $loader->load($filePath); + } + + public function getPathsToInvalidFiles() + { + yield 'defining controller' => ['with_controller_attribute.yaml', 'The routing file "%s" must not specify both the "controller" and the "template" keys for "invalid_route".']; + yield 'defining template and redirect' => ['template_and_redirect.yaml', 'The routing file "%s" must not specify only one route type among "template", "redirect_to_route" keys for "invalid_route".']; + } +} diff --git a/src/Symfony/Component/Routing/Loader/XmlFileLoader.php b/src/Symfony/Component/Routing/Loader/XmlFileLoader.php index 29d3e4a7714d5..ca8564e046050 100644 --- a/src/Symfony/Component/Routing/Loader/XmlFileLoader.php +++ b/src/Symfony/Component/Routing/Loader/XmlFileLoader.php @@ -30,7 +30,7 @@ class XmlFileLoader extends FileLoader use PrefixTrait; const NAMESPACE_URI = 'http://symfony.com/schema/routing'; - const SCHEME_PATH = '/schema/routing/routing-1.0.xsd'; + const SCHEME_PATH = __DIR__.'/schema/routing/routing-1.0.xsd'; /** * Loads an XML file. @@ -229,7 +229,7 @@ protected function parseImport(RouteCollection $collection, \DOMElement $node, s */ protected function loadFile(string $file) { - return XmlUtils::loadFile($file, __DIR__.static::SCHEME_PATH); + return XmlUtils::loadFile($file, static::SCHEME_PATH); } /** @@ -303,7 +303,7 @@ private function parseConfigs(\DOMElement $node, string $path): array if (isset($defaults['_stateless'])) { $name = $node->hasAttribute('id') ? sprintf('"%s"', $node->getAttribute('id')) : sprintf('the "%s" tag', $node->tagName); - throw new \InvalidArgumentException(sprintf('The routing file "%s" must not specify both the "stateless" attribute and the defaults key "_stateless" for %s.', $path, $name)); + throw new \InvalidArgumentException(sprintf('The routing file "%s" must not specify both the "stateless" attribute and the defaults key "_stateless" for "%s".', $path, $name)); } $defaults['_stateless'] = XmlUtils::phpize($stateless); @@ -317,7 +317,7 @@ private function parseConfigs(\DOMElement $node, string $path): array * * @return array|bool|float|int|string|null The parsed value of the "default" element */ - private function parseDefaultsConfig(\DOMElement $element, string $path) + final protected function parseDefaultsConfig(\DOMElement $element, string $path) { if ($this->isElementValueNull($element)) { return null;