feature #15778 Fluid interface for building routes in PHP (weaverryan)

This PR was squashed before being merged into the 2.8 branch (closes #15778).

Discussion
----------

Fluid interface for building routes in PHP

| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | n/a
| License       | MIT
| Doc PR        | not yet...

This - along with #15742 - attempts to making adding routes in PHP (via an actual class+method) not only possible, but also useful.

The two classes - `Route` and `RouteCollectionBuilder` are based off of Silex's `Controller` and `ControllerCollection`. The `RouteCollectionBuilder` is basically a `RouteCollection` that's able to import other resources. Here are the goals:

A) Import routes easily

```php
$routes->import('routing.yml');
```

B) Fluid addition of routes into the collection

```php
$routes->add('/admin', 'AppBundle:Admin:index', 'admin_index')
    ->setMethods(['GET']);
```

C) Ability to create routes with auto-generating names

D) Ability to add a "sub-collection" (kind of like an import, without pointing to another file). Included is the ability to set the controller class:

```php
$blogRoutes = $routes->createBuilder('/blog')
   ->setControllerClass('AppBundle\Controller\BlogController');
$blogRoutes->add('/', 'indexAction');
$blogRoutes->add('/{id}', 'editAction');
$routes->addBuilder($blogRoutes);
```

E) The collection options can be set before or after the routes. With `RouteCollection`, if you set something - e.g. a prefix or a default - and THEN add more routes, those options are not passed to those routes. This is by design, but not ideal for building routes (e.g. in the previous code example, the controllerClass would not be applied using the opposite logic, since it's set before adding the routes).

Thanks!

Commits
-------

15ba2e8 Fluid interface for building routes in PHP
This commit is contained in:
Fabien Potencier 2015-10-01 22:32:51 +02:00
commit 54e3d71c03
2 changed files with 665 additions and 0 deletions

View File

@ -0,0 +1,348 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Routing;
use Symfony\Component\Config\Exception\FileLoaderLoadException;
use Symfony\Component\Config\Loader\LoaderInterface;
/**
* Helps add and import routes into a RouteCollection.
*
* @author Ryan Weaver <ryan@knpuniversity.com>
*/
class RouteCollectionBuilder
{
/**
* @var Route[]|RouteCollectionBuilder[]
*/
private $routes = array();
private $loader;
private $defaults = array();
private $prefix;
private $host;
private $condition;
private $requirements = array();
private $options = array();
private $schemes;
private $methods;
/**
* @param LoaderInterface $loader
*/
public function __construct(LoaderInterface $loader = null)
{
$this->loader = $loader;
}
/**
* Import an external routing resource and returns the RouteCollectionBuilder.
*
* $routes->mount('/blog', $routes->import('blog.yml'));
*
* @param mixed $resource
* @param string $type
*
* @return RouteCollectionBuilder
*
* @throws FileLoaderLoadException
*/
public function import($resource, $type = null)
{
/** @var RouteCollection $collection */
$collection = $this->load($resource, $type);
// create a builder from the RouteCollection
$builder = $this->createBuilder();
foreach ($collection->all() as $name => $route) {
$builder->addRoute($route, $name);
}
foreach ($collection->getResources() as $resource) {
$builder->addResource($resource);
}
return $builder;
}
/**
* Adds a route and returns it for future modification.
*
* @param string $path The route path
* @param string $controller The route's controller
* @param string|null $name The name to give this route
*
* @return Route
*/
public function add($path, $controller, $name = null)
{
$route = new Route($path);
$route->setDefault('_controller', $controller);
$this->addRoute($route, $name);
return $route;
}
/**
* Returns a RouteCollectionBuilder that can be configured and then added with mount().
*
* @return RouteCollectionBuilder
*/
public function createBuilder()
{
return new self($this->loader);
}
/**
* Add a RouteCollectionBuilder.
*
* @param RouteCollectionBuilder $builder
*/
public function mount($prefix, RouteCollectionBuilder $builder)
{
$builder->prefix = trim(trim($prefix), '/');
$this->routes[] = $builder;
}
/**
* Adds a Route object to the builder.
*
* @param Route $route
* @param string|null $name
*
* @return $this
*/
public function addRoute(Route $route, $name = null)
{
if (null === $name) {
// used as a flag to know which routes will need a name later
$name = '_unnamed_route_'.spl_object_hash($route);
}
$this->routes[$name] = $route;
return $this;
}
/**
* Sets the host on all embedded routes (unless already set).
*
* @param string $pattern
*
* @return $this
*/
public function setHost($pattern)
{
$this->host = $pattern;
return $this;
}
/**
* Sets a condition on all embedded routes (unless already set).
*
* @param string $condition
*
* @return $this
*/
public function setCondition($condition)
{
$this->condition = $condition;
return $this;
}
/**
* Sets a default value that will be added to all embedded routes (unless that
* default value is already set.
*
* @param string $key
* @param mixed $value
*
* @return $this
*/
public function setDefault($key, $value)
{
$this->defaults[$key] = $value;
return $this;
}
/**
* Sets a requirement that will be added to all embedded routes (unless that
* requirement is already set.
*
* @param string $key
* @param mixed $regex
*
* @return $this
*/
public function setRequirement($key, $regex)
{
$this->requirements[$key] = $regex;
return $this;
}
/**
* Sets an opiton that will be added to all embedded routes (unless that
* option is already set.
*
* @param string $key
* @param mixed $value
*
* @return $this
*/
public function setOption($key, $value)
{
$this->options[$key] = $value;
return $this;
}
/**
* Sets the schemes on all embedded routes (unless already set).
*
* @param array|string $schemes
*
* @return $this
*/
public function setSchemes($schemes)
{
$this->schemes = $schemes;
return $this;
}
/**
* Sets the methods on all embedded routes (unless already set).
*
* @param array|string $methods
*
* @return $this
*/
public function setMethods($methods)
{
$this->methods = $methods;
return $this;
}
/**
* Creates the final ArrayCollection, returns it, and clears everything.
*
* @return RouteCollection
*/
public function build()
{
$routeCollection = new RouteCollection();
foreach ($this->routes as $name => $route) {
if ($route instanceof Route) {
$route->setDefaults(array_merge($this->defaults, $route->getDefaults()));
$route->setOptions(array_merge($this->options, $route->getOptions()));
// we're extra careful here to avoid re-setting deprecated _method and _scheme
foreach ($this->requirements as $key => $val) {
if (!$route->hasRequirement($key)) {
$route->setRequirement($key, $val);
}
}
if (null !== $this->prefix) {
$route->setPath('/'.$this->prefix.$route->getPath());
}
if (!$route->getHost()) {
$route->setHost($this->host);
}
if (!$route->getCondition()) {
$route->setCondition($this->condition);
}
if (!$route->getSchemes()) {
$route->setSchemes($this->schemes);
}
if (!$route->getMethods()) {
$route->setMethods($this->methods);
}
// auto-generate the route name if it's been marked
if ('_unnamed_route_' === substr($name, 0, 15)) {
$name = $this->generateRouteName($route);
}
$routeCollection->add($name, $route);
} else {
/* @var self $route */
$subCollection = $route->build();
$subCollection->addPrefix($this->prefix);
$routeCollection->addCollection($subCollection);
}
}
return $routeCollection;
}
/**
* Generates a route name based on details of this route.
*
* @return string
*/
private function generateRouteName(Route $route)
{
$methods = implode('_', $route->getMethods()).'_';
$routeName = $methods.$route->getPath();
$routeName = str_replace(array('/', ':', '|', '-'), '_', $routeName);
$routeName = preg_replace('/[^a-z0-9A-Z_.]+/', '', $routeName);
// Collapse consecutive underscores down into a single underscore.
$routeName = preg_replace('/_+/', '_', $routeName);
return $routeName;
}
/**
* Finds a loader able to load an imported resource and loads it.
*
* @param mixed $resource A resource
* @param string|null $type The resource type or null if unknown
*
* @return RouteCollection
*
* @throws FileLoaderLoadException If no loader is found
*/
private function load($resource, $type = null)
{
if (null === $this->loader) {
throw new \BadMethodCallException('Cannot import other routing resources: you must pass a LoaderInterface when constructing RouteCollectionBuilder.');
}
if ($this->loader->supports($resource, $type)) {
return $this->loader->load($resource, $type);
}
if (null === $resolver = $this->loader->getResolver()) {
throw new FileLoaderLoadException($resource);
}
if (false === $loader = $resolver->resolve($resource, $type)) {
throw new FileLoaderLoadException($resource);
}
return $loader->load($resource, $type);
}
}

View File

@ -0,0 +1,317 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Routing\Tests;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
use Symfony\Component\Routing\RouteCollectionBuilder;
class RouteCollectionBuilderTest extends \PHPUnit_Framework_TestCase
{
public function testImport()
{
$resolvedLoader = $this->getMock('Symfony\Component\Config\Loader\LoaderInterface');
$resolver = $this->getMock('Symfony\Component\Config\Loader\LoaderResolverInterface');
$resolver->expects($this->once())
->method('resolve')
->with('admin_routing.yml', 'yaml')
->will($this->returnValue($resolvedLoader));
$originalRoute = new Route('/foo/path');
$expectedCollection = new RouteCollection();
$expectedCollection->add('one_test_route', $originalRoute);
$resolvedLoader
->expects($this->once())
->method('load')
->with('admin_routing.yml', 'yaml')
->will($this->returnValue($expectedCollection));
$loader = $this->getMock('Symfony\Component\Config\Loader\LoaderInterface');
$loader->expects($this->any())
->method('getResolver')
->will($this->returnValue($resolver));
// import the file!
$routes = new RouteCollectionBuilder($loader);
$importedRoutes = $routes->import('admin_routing.yml', 'yaml');
// we should get back a RouteCollectionBuilder
$this->assertInstanceOf('Symfony\Component\Routing\RouteCollectionBuilder', $importedRoutes);
// get the collection back so we can look at it
$addedCollection = $importedRoutes->build();
$route = $addedCollection->get('one_test_route');
$this->assertSame($originalRoute, $route);
}
/**
* @expectedException \BadMethodCallException
*/
public function testImportWithoutLoaderThrowsException()
{
$collectionBuilder = new RouteCollectionBuilder();
$collectionBuilder->import('routing.yml');
}
public function testAdd()
{
$collectionBuilder = new RouteCollectionBuilder();
$addedRoute = $collectionBuilder->add('/checkout', 'AppBundle:Order:checkout');
$addedRoute2 = $collectionBuilder->add('/blogs', 'AppBundle:Blog:list', 'blog_list');
$this->assertInstanceOf('Symfony\Component\Routing\Route', $addedRoute);
$this->assertEquals('AppBundle:Order:checkout', $addedRoute->getDefault('_controller'));
$finalCollection = $collectionBuilder->build();
$this->assertSame($addedRoute2, $finalCollection->get('blog_list'));
}
public function testFlushOrdering()
{
$importedCollection = new RouteCollection();
$importedCollection->add('imported_route1', new Route('/imported/foo1'));
$importedCollection->add('imported_route2', new Route('/imported/foo2'));
$loader = $this->getMock('Symfony\Component\Config\Loader\LoaderInterface');
// make this loader able to do the import - keeps mocking simple
$loader->expects($this->any())
->method('supports')
->will($this->returnValue(true));
$loader
->expects($this->once())
->method('load')
->will($this->returnValue($importedCollection));
$routes = new RouteCollectionBuilder($loader);
// 1) Add a route
$routes->add('/checkout', 'AppBundle:Order:checkout', 'checkout_route');
// 2) Import from a file
$routes->mount('/', $routes->import('admin_routing.yml'));
// 3) Add another route
$routes->add('/', 'AppBundle:Default:homepage', 'homepage');
// 4) Add another route
$routes->add('/admin', 'AppBundle:Admin:dashboard', 'admin_dashboard');
// set a default value
$routes->setDefault('_locale', 'fr');
$actualCollection = $routes->build();
$this->assertCount(5, $actualCollection);
$actualRouteNames = array_keys($actualCollection->all());
$this->assertEquals(array(
'checkout_route',
'imported_route1',
'imported_route2',
'homepage',
'admin_dashboard',
), $actualRouteNames);
// make sure the defaults were set
$checkoutRoute = $actualCollection->get('checkout_route');
$defaults = $checkoutRoute->getDefaults();
$this->assertArrayHasKey('_locale', $defaults);
$this->assertEquals('fr', $defaults['_locale']);
}
public function testFlushSetsRouteNames()
{
$collectionBuilder = new RouteCollectionBuilder();
// add a "named" route
$collectionBuilder->add('/admin', 'AppBundle:Admin:dashboard', 'admin_dashboard');
// add an unnamed route
$collectionBuilder->add('/blogs', 'AppBundle:Blog:list')
->setMethods(array('GET'));
// integer route names are allowed - they don't confuse things
$collectionBuilder->add('/products', 'AppBundle:Product:list', 100);
$actualCollection = $collectionBuilder->build();
$actualRouteNames = array_keys($actualCollection->all());
$this->assertEquals(array(
'admin_dashboard',
'GET_blogs',
'100',
), $actualRouteNames);
}
public function testFlushSetsDetailsOnChildrenRoutes()
{
$routes = new RouteCollectionBuilder();
$routes->add('/blogs/{page}', 'listAction', 'blog_list')
// unique things for the route
->setDefault('page', 1)
->setRequirement('id', '\d+')
->setOption('expose', true)
// things that the collection will try to override (but won't)
->setDefault('_format', 'html')
->setRequirement('_format', 'json|xml')
->setOption('fooBar', true)
->setHost('example.com')
->setCondition('request.isSecure()')
->setSchemes(array('https'))
->setMethods(array('POST'));
// a simple route, nothing added to it
$routes->add('/blogs/{id}', 'editAction', 'blog_edit');
// configure the collection itself
$routes
// things that will not override the child route
->setDefault('_format', 'json')
->setRequirement('_format', 'xml')
->setOption('fooBar', false)
->setHost('symfony.com')
->setCondition('request.query.get("page")==1')
// some unique things that should be set on the child
->setDefault('_locale', 'fr')
->setRequirement('_locale', 'fr|en')
->setOption('niceRoute', true)
->setSchemes(array('http'))
->setMethods(array('GET', 'POST'));
$collection = $routes->build();
$actualListRoute = $collection->get('blog_list');
$this->assertEquals(1, $actualListRoute->getDefault('page'));
$this->assertEquals('\d+', $actualListRoute->getRequirement('id'));
$this->assertTrue($actualListRoute->getOption('expose'));
// none of these should be overridden
$this->assertEquals('html', $actualListRoute->getDefault('_format'));
$this->assertEquals('json|xml', $actualListRoute->getRequirement('_format'));
$this->assertTrue($actualListRoute->getOption('fooBar'));
$this->assertEquals('example.com', $actualListRoute->getHost());
$this->assertEquals('request.isSecure()', $actualListRoute->getCondition());
$this->assertEquals(array('https'), $actualListRoute->getSchemes());
$this->assertEquals(array('POST'), $actualListRoute->getMethods());
// inherited from the main collection
$this->assertEquals('fr', $actualListRoute->getDefault('_locale'));
$this->assertEquals('fr|en', $actualListRoute->getRequirement('_locale'));
$this->assertTrue($actualListRoute->getOption('niceRoute'));
$actualEditRoute = $collection->get('blog_edit');
// inherited from the collection
$this->assertEquals('symfony.com', $actualEditRoute->getHost());
$this->assertEquals('request.query.get("page")==1', $actualEditRoute->getCondition());
$this->assertEquals(array('http'), $actualEditRoute->getSchemes());
$this->assertEquals(array('GET', 'POST'), $actualEditRoute->getMethods());
}
/**
* @dataProvider providePrefixTests
*/
public function testFlushPrefixesPaths($collectionPrefix, $routePath, $expectedPath)
{
$routes = new RouteCollectionBuilder();
$routes->add($routePath, 'someController', 'test_route');
$outerRoutes = new RouteCollectionBuilder();
$outerRoutes->mount($collectionPrefix, $routes);
$collection = $outerRoutes->build();
$this->assertEquals($expectedPath, $collection->get('test_route')->getPath());
}
public function providePrefixTests()
{
$tests = array();
// empty prefix is of course ok
$tests[] = array('', '/foo', '/foo');
// normal prefix - does not matter if it's a wildcard
$tests[] = array('/{admin}', '/foo', '/{admin}/foo');
// shows that a prefix will always be given the starting slash
$tests[] = array('0', '/foo', '/0/foo');
// spaces are ok, and double slahses at the end are cleaned
$tests[] = array('/ /', '/foo', '/ /foo');
return $tests;
}
public function testFlushSetsPrefixedWithMultipleLevels()
{
$loader = $this->getMock('Symfony\Component\Config\Loader\LoaderInterface');
$routes = new RouteCollectionBuilder($loader);
$routes->add('homepage', 'MainController::homepageAction', 'homepage');
$adminRoutes = $routes->createBuilder();
$adminRoutes->add('/dashboard', 'AdminController::dashboardAction', 'admin_dashboard');
// embedded collection under /admin
$adminBlogRoutes = $routes->createBuilder();
$adminBlogRoutes->add('/new', 'BlogController::newAction', 'admin_blog_new');
// mount into admin, but before the parent collection has been mounted
$adminRoutes->mount('/blog', $adminBlogRoutes);
// now mount the /admin routes, above should all still be /blog/admin
$routes->mount('/admin', $adminRoutes);
// add a route after mounting
$adminRoutes->add('/users', 'AdminController::userAction', 'admin_users');
// add another sub-collection after the mount
$otherAdminRoutes = $routes->createBuilder();
$otherAdminRoutes->add('/sales', 'StatsController::indexAction', 'admin_stats_sales');
$adminRoutes->mount('/stats', $otherAdminRoutes);
// add a normal collection and see that it is also prefixed
$importedCollection = new RouteCollection();
$importedCollection->add('imported_route', new Route('/foo'));
// make this loader able to do the import - keeps mocking simple
$loader->expects($this->any())
->method('supports')
->will($this->returnValue(true));
$loader
->expects($this->any())
->method('load')
->will($this->returnValue($importedCollection));
// import this from the /admin route builder
$adminRoutes->mount('/imported', $adminRoutes->import('admin.yml'));
$collection = $routes->build();
$this->assertEquals('/admin/dashboard', $collection->get('admin_dashboard')->getPath(), 'Routes before mounting have the prefix');
$this->assertEquals('/admin/users', $collection->get('admin_users')->getPath(), 'Routes after mounting have the prefix');
$this->assertEquals('/admin/blog/new', $collection->get('admin_blog_new')->getPath(), 'Sub-collections receive prefix even if mounted before parent prefix');
$this->assertEquals('/admin/stats/sales', $collection->get('admin_stats_sales')->getPath(), 'Sub-collections receive prefix if mounted after parent prefix');
$this->assertEquals('/admin/imported/foo', $collection->get('imported_route')->getPath(), 'Normal RouteCollections are also prefixed properly');
}
public function testAutomaticRouteNamesDoNotConflict()
{
$routes = new RouteCollectionBuilder();
$adminRoutes = $routes->createBuilder();
// route 1
$adminRoutes->add('/dashboard', '');
$accountRoutes = $routes->createBuilder();
// route 2
$accountRoutes->add('/dashboard', '')
->setMethods(array('GET'));
// route 3
$accountRoutes->add('/dashboard', '')
->setMethods(array('POST'));
$routes->mount('/admin', $adminRoutes);
$routes->mount('/account', $accountRoutes);
$collection = $routes->build();
// there are 2 routes (i.e. with non-conflicting names)
$this->assertCount(3, $collection->all());
}
}