From f433c9a79dc0983e6067be9db7aeacc81646f4ca Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 11 Sep 2017 10:46:30 +0200 Subject: [PATCH] [Routing] Add PHP fluent DSL for configuring routes --- .../Configurator/CollectionConfigurator.php | 79 ++++++++++ .../Configurator/ImportConfigurator.php | 49 +++++++ .../Loader/Configurator/RouteConfigurator.php | 31 ++++ .../Configurator/RoutingConfigurator.php | 54 +++++++ .../Loader/Configurator/Traits/AddTrait.php | 54 +++++++ .../Loader/Configurator/Traits/RouteTrait.php | 137 ++++++++++++++++++ .../Routing/Loader/PhpFileLoader.php | 35 +++-- .../Routing/Tests/Fixtures/php_dsl.php | 21 +++ .../Routing/Tests/Fixtures/php_dsl_sub.php | 14 ++ .../Tests/Loader/PhpFileLoaderTest.php | 37 +++++ 10 files changed, 498 insertions(+), 13 deletions(-) create mode 100644 src/Symfony/Component/Routing/Loader/Configurator/CollectionConfigurator.php create mode 100644 src/Symfony/Component/Routing/Loader/Configurator/ImportConfigurator.php create mode 100644 src/Symfony/Component/Routing/Loader/Configurator/RouteConfigurator.php create mode 100644 src/Symfony/Component/Routing/Loader/Configurator/RoutingConfigurator.php create mode 100644 src/Symfony/Component/Routing/Loader/Configurator/Traits/AddTrait.php create mode 100644 src/Symfony/Component/Routing/Loader/Configurator/Traits/RouteTrait.php create mode 100644 src/Symfony/Component/Routing/Tests/Fixtures/php_dsl.php create mode 100644 src/Symfony/Component/Routing/Tests/Fixtures/php_dsl_sub.php diff --git a/src/Symfony/Component/Routing/Loader/Configurator/CollectionConfigurator.php b/src/Symfony/Component/Routing/Loader/Configurator/CollectionConfigurator.php new file mode 100644 index 0000000000000..67d5c77de5459 --- /dev/null +++ b/src/Symfony/Component/Routing/Loader/Configurator/CollectionConfigurator.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Routing\Loader\Configurator; + +use Symfony\Component\Routing\Route; +use Symfony\Component\Routing\RouteCollection; + +/** + * @author Nicolas Grekas + */ +class CollectionConfigurator +{ + use Traits\AddTrait; + use Traits\RouteTrait; + + private $parent; + + public function __construct(RouteCollection $parent, $name) + { + $this->parent = $parent; + $this->name = $name; + $this->collection = new RouteCollection(); + $this->route = new Route(''); + } + + public function __destruct() + { + $this->collection->addPrefix(rtrim($this->route->getPath(), '/')); + $this->parent->addCollection($this->collection); + } + + /** + * Adds a route. + * + * @param string $name + * @param string $value + * + * @return RouteConfigurator + */ + final public function add($name, $path) + { + $this->collection->add($this->name.$name, $route = clone $this->route); + + return new RouteConfigurator($this->collection, $route->setPath($path), $this->name); + } + + /** + * Creates a sub-collection. + * + * @return self + */ + final public function collection($name = '') + { + return new self($this->collection, $this->name.$name); + } + + /** + * Sets the prefix to add to the path of all child routes. + * + * @param string $prefix + * + * @return $this + */ + final public function prefix($prefix) + { + $this->route->setPath($prefix); + + return $this; + } +} diff --git a/src/Symfony/Component/Routing/Loader/Configurator/ImportConfigurator.php b/src/Symfony/Component/Routing/Loader/Configurator/ImportConfigurator.php new file mode 100644 index 0000000000000..d0a3c373ff23a --- /dev/null +++ b/src/Symfony/Component/Routing/Loader/Configurator/ImportConfigurator.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\Component\Routing\Loader\Configurator; + +use Symfony\Component\Routing\RouteCollection; + +/** + * @author Nicolas Grekas + */ +class ImportConfigurator +{ + use Traits\RouteTrait; + + private $parent; + + public function __construct(RouteCollection $parent, RouteCollection $route) + { + $this->parent = $parent; + $this->route = $route; + } + + public function __destruct() + { + $this->parent->addCollection($this->route); + } + + /** + * Sets the prefix to add to the path of all child routes. + * + * @param string $prefix + * + * @return $this + */ + final public function prefix($prefix) + { + $this->route->addPrefix($prefix); + + return $this; + } +} diff --git a/src/Symfony/Component/Routing/Loader/Configurator/RouteConfigurator.php b/src/Symfony/Component/Routing/Loader/Configurator/RouteConfigurator.php new file mode 100644 index 0000000000000..b8d87025435e0 --- /dev/null +++ b/src/Symfony/Component/Routing/Loader/Configurator/RouteConfigurator.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Routing\Loader\Configurator; + +use Symfony\Component\Routing\Route; +use Symfony\Component\Routing\RouteCollection; + +/** + * @author Nicolas Grekas + */ +class RouteConfigurator +{ + use Traits\AddTrait; + use Traits\RouteTrait; + + public function __construct(RouteCollection $collection, Route $route, $name = '') + { + $this->collection = $collection; + $this->route = $route; + $this->name = $name; + } +} diff --git a/src/Symfony/Component/Routing/Loader/Configurator/RoutingConfigurator.php b/src/Symfony/Component/Routing/Loader/Configurator/RoutingConfigurator.php new file mode 100644 index 0000000000000..4591a86ba5cf9 --- /dev/null +++ b/src/Symfony/Component/Routing/Loader/Configurator/RoutingConfigurator.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Routing\Loader\Configurator; + +use Symfony\Component\Routing\Loader\PhpFileLoader; +use Symfony\Component\Routing\RouteCollection; + +/** + * @author Nicolas Grekas + */ +class RoutingConfigurator +{ + use Traits\AddTrait; + + private $loader; + private $path; + private $file; + + public function __construct(RouteCollection $collection, PhpFileLoader $loader, $path, $file) + { + $this->collection = $collection; + $this->loader = $loader; + $this->path = $path; + $this->file = $file; + } + + /** + * @return ImportConfigurator + */ + final public function import($resource, $type = null, $ignoreErrors = false) + { + $this->loader->setCurrentDir(dirname($this->path)); + $subCollection = $this->loader->import($resource, $type, $ignoreErrors, $this->file); + + return new ImportConfigurator($this->collection, $subCollection); + } + + /** + * @return CollectionConfigurator + */ + final public function collection($name = '') + { + return new CollectionConfigurator($this->collection, $name); + } +} diff --git a/src/Symfony/Component/Routing/Loader/Configurator/Traits/AddTrait.php b/src/Symfony/Component/Routing/Loader/Configurator/Traits/AddTrait.php new file mode 100644 index 0000000000000..abaf868c9c698 --- /dev/null +++ b/src/Symfony/Component/Routing/Loader/Configurator/Traits/AddTrait.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Routing\Loader\Configurator\Traits; + +use Symfony\Component\Routing\Loader\Configurator\RouteConfigurator; +use Symfony\Component\Routing\Route; +use Symfony\Component\Routing\RouteCollection; + +trait AddTrait +{ + /** + * @var RouteCollection + */ + private $collection; + + private $name = ''; + + /** + * Adds a route. + * + * @param string $name + * @param string $value + * + * @return RouteConfigurator + */ + final public function add($name, $path) + { + $this->collection->add($this->name.$name, $route = new Route($path)); + + return new RouteConfigurator($this->collection, $route); + } + + /** + * Adds a route. + * + * @param string $name + * @param string $path + * + * @return RouteConfigurator + */ + final public function __invoke($name, $path) + { + return $this->add($name, $path); + } +} diff --git a/src/Symfony/Component/Routing/Loader/Configurator/Traits/RouteTrait.php b/src/Symfony/Component/Routing/Loader/Configurator/Traits/RouteTrait.php new file mode 100644 index 0000000000000..8eb3276500d89 --- /dev/null +++ b/src/Symfony/Component/Routing/Loader/Configurator/Traits/RouteTrait.php @@ -0,0 +1,137 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Routing\Loader\Configurator\Traits; + +use Symfony\Component\Routing\Route; +use Symfony\Component\Routing\RouteCollection; + +trait RouteTrait +{ + /** + * @var RouteCollection|Route + */ + private $route; + + /** + * Adds defaults. + * + * @param array $defaults + * + * @return $this + */ + final public function defaults(array $defaults) + { + $this->route->addDefaults($defaults); + + return $this; + } + + /** + * Adds requirements. + * + * @param array $requirements + * + * @return $this + */ + final public function requirements(array $requirements) + { + $this->route->addRequirements($requirements); + + return $this; + } + + /** + * Adds options. + * + * @param array $options + * + * @return $this + */ + final public function options(array $options) + { + $this->route->addOptions($options); + + return $this; + } + + /** + * Sets the condition. + * + * @param string $condition + * + * @return $this + */ + final public function condition($condition) + { + $this->route->setCondition($condition); + + return $this; + } + + /** + * Sets the pattern for the host. + * + * @param string $pattern + * + * @return $this + */ + final public function host($pattern) + { + $this->route->setHost($pattern); + + return $this; + } + + /** + * Sets the schemes (e.g. 'https') this route is restricted to. + * So an empty array means that any scheme is allowed. + * + * @param array $schemes + * + * @return $this + */ + final public function schemes(array $schemes) + { + $this->route->setSchemes($schemes); + + return $this; + } + + /** + * Sets the HTTP methods (e.g. 'POST') this route is restricted to. + * So an empty array means that any method is allowed. + * + * @param array $methods + * + * @return $this + */ + final public function methods(array $methods) + { + $this->route->setMethods($methods); + + return $this; + } + + /** + * Adds the "_controller" entry to defaults. + * + * @param callable $controller a callable or parseable pseudo-callable + * + * @return $this + */ + final public function controller($controller) + { + $this->route->addDefaults(array('_controller' => $controller)); + + return $this; + } +} diff --git a/src/Symfony/Component/Routing/Loader/PhpFileLoader.php b/src/Symfony/Component/Routing/Loader/PhpFileLoader.php index b4ba5fbc7f8ba..3fcd692f92562 100644 --- a/src/Symfony/Component/Routing/Loader/PhpFileLoader.php +++ b/src/Symfony/Component/Routing/Loader/PhpFileLoader.php @@ -13,6 +13,7 @@ use Symfony\Component\Config\Loader\FileLoader; use Symfony\Component\Config\Resource\FileResource; +use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; use Symfony\Component\Routing\RouteCollection; /** @@ -37,7 +38,21 @@ public function load($file, $type = null) $path = $this->locator->locate($file); $this->setCurrentDir(dirname($path)); - $collection = self::includeFile($path, $this); + // the closure forbids access to the private scope in the included file + $loader = $this; + $load = \Closure::bind(function ($file) use ($loader) { + return include $file; + }, null, ProtectedPhpFileLoader::class); + + $result = $load($path); + + if ($result instanceof \Closure) { + $collection = new RouteCollection(); + $result(new RoutingConfigurator($collection, $this, $path, $file), $this); + } else { + $collection = $result; + } + $collection->addResource(new FileResource($path)); return $collection; @@ -50,17 +65,11 @@ public function supports($resource, $type = null) { return is_string($resource) && 'php' === pathinfo($resource, PATHINFO_EXTENSION) && (!$type || 'php' === $type); } +} - /** - * Safe include. Used for scope isolation. - * - * @param string $file File to include - * @param PhpFileLoader $loader the loader variable is exposed to the included file below - * - * @return RouteCollection - */ - private static function includeFile($file, PhpFileLoader $loader) - { - return include $file; - } +/** + * @internal + */ +final class ProtectedPhpFileLoader extends PhpFileLoader +{ } diff --git a/src/Symfony/Component/Routing/Tests/Fixtures/php_dsl.php b/src/Symfony/Component/Routing/Tests/Fixtures/php_dsl.php new file mode 100644 index 0000000000000..04f6d7ed6eab2 --- /dev/null +++ b/src/Symfony/Component/Routing/Tests/Fixtures/php_dsl.php @@ -0,0 +1,21 @@ +add('foo', '/foo') + ->condition('abc') + ->options(array('utf8' => true)) + ->add('buz', 'zub') + ->controller('foo:act'); + + $routes->import('php_dsl_sub.php') + ->prefix('/sub') + ->requirements(array('id' => '\d+')); + + $routes->add('ouf', '/ouf') + ->schemes(array('https')) + ->methods(array('GET')) + ->defaults(array('id' => 0)); +}; diff --git a/src/Symfony/Component/Routing/Tests/Fixtures/php_dsl_sub.php b/src/Symfony/Component/Routing/Tests/Fixtures/php_dsl_sub.php new file mode 100644 index 0000000000000..9eb444ded0c1c --- /dev/null +++ b/src/Symfony/Component/Routing/Tests/Fixtures/php_dsl_sub.php @@ -0,0 +1,14 @@ +collection('c_') + ->prefix('pub'); + + $add('bar', '/bar'); + + $add->collection('pub_') + ->host('host') + ->add('buz', 'buz'); +}; diff --git a/src/Symfony/Component/Routing/Tests/Loader/PhpFileLoaderTest.php b/src/Symfony/Component/Routing/Tests/Loader/PhpFileLoaderTest.php index bda64236f6022..608d84ed7a20f 100644 --- a/src/Symfony/Component/Routing/Tests/Loader/PhpFileLoaderTest.php +++ b/src/Symfony/Component/Routing/Tests/Loader/PhpFileLoaderTest.php @@ -13,7 +13,10 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Config\FileLocator; +use Symfony\Component\Config\Resource\FileResource; use Symfony\Component\Routing\Loader\PhpFileLoader; +use Symfony\Component\Routing\Route; +use Symfony\Component\Routing\RouteCollection; class PhpFileLoaderTest extends TestCase { @@ -80,4 +83,38 @@ public function testThatDefiningVariableInConfigFileHasNoSideEffects() (string) $fileResource ); } + + public function testRoutingConfigurator() + { + $locator = new FileLocator(array(__DIR__.'/../Fixtures')); + $loader = new PhpFileLoader($locator); + $routeCollection = $loader->load('php_dsl.php'); + + $expectedCollection = new RouteCollection(); + + $expectedCollection->add('foo', (new Route('/foo')) + ->setOptions(array('utf8' => true)) + ->setCondition('abc') + ); + $expectedCollection->add('buz', (new Route('/zub')) + ->setDefaults(array('_controller' => 'foo:act')) + ); + $expectedCollection->add('c_bar', (new Route('/sub/pub/bar')) + ->setRequirements(array('id' => '\d+')) + ); + $expectedCollection->add('c_pub_buz', (new Route('/sub/pub/buz')) + ->setHost('host') + ->setRequirements(array('id' => '\d+')) + ); + $expectedCollection->add('ouf', (new Route('/ouf')) + ->setSchemes(array('https')) + ->setMethods(array('GET')) + ->setDefaults(array('id' => 0)) + ); + + $expectedCollection->addResource(new FileResource(realpath(__DIR__.'/../Fixtures/php_dsl_sub.php'))); + $expectedCollection->addResource(new FileResource(realpath(__DIR__.'/../Fixtures/php_dsl.php'))); + + $this->assertEquals($expectedCollection, $routeCollection); + } }