Fluid interface for building routes in PHP

This commit is contained in:
Ryan Weaver 2015-09-08 20:28:50 -04:00 committed by Fabien Potencier
parent 00dffe73b8
commit 15ba2e87a4
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());
}
}