-
-
Notifications
You must be signed in to change notification settings - Fork 9.7k
Fluid interface for building routes in PHP #15778
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
51f60fc
6891ec8
4e430a0
0ddadc5
06ea900
6b922a6
14518ed
7972fc9
e11b7e0
4d90916
729ccbb
e509953
01e1329
e39e0c4
df1849f
97b1eea
e1ecde4
ecf4346
0dce55d
f3d71ad
8132fcb
b2676ec
bf6790b
61e4bf7
8f0b956
fbab6d4
012cb92
33255dd
356b114
7cb8996
573a7f1
83f3194
26d656b
42e73a2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a bit counter-intuitive as almost all other methods return the builder itself. Though probably there is no better solution. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I almost wrote the same comment but I think it makes sense for this one to return the route. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The name is disturbing, you don not expect a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I tried to make this method the most easily guessable, since you will call this much more often than There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It also makes sense, as there is no There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When looking at the code written with this builder, |
||
*/ | ||
public function add($path, $controller, $name = null) | ||
{ | ||
$route = new Route($path); | ||
$route->setDefault('_controller', $controller); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The routing component doesn't know anything about a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @Tobion you're right that the Routing component doesn't know about There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If HttpKernel does, why not putting it in HttpKernel instead? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we put it in HttpKernel, someone will ask why we don't put it in Routing :) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. And then we have a clear answer. HttpKernel contains classes that add just a little "HttpKernel convention sauce" on top of classes provided by other components (DI Extension, File locator, ...) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good point. My counterpoint is that it makes sense to use this without HttpKernel - maybe you have a system where _controller is meaningful or you override this method. That should be possible without requiring http-kernel, which this class doesn't actually depend on. So there's no perfect spot, but I think this is best |
||
$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. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ArrayCollection? |
||
* | ||
* @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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this method seems very vulnerable to collisions. It only uses the path and methods? What about the scheme, host, condition etc? So two homepage routes under different domains, would have the same name and thus overwrite each other? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's true, but we can probably not ever consider all the conditions. So, the question is do we try? Or do we make this work for most cases, and really, you should be naming your routes. The truth is that I based this off of what SensioFrameworkExtraBundle and Silex does. I don't remember if the logic is exactly the same here, but those are also both vulnerable to collision. |
||
{ | ||
$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); | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I still prefer my suggestion from a previous version of your code. Here, you artificially use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I didn't purposefully ignore your suggestion, it's here #15778 (comment) - but I think it's more complicated. The original code comes from the Config component: https://github.com/symfony/symfony/blob/2.8/src/Symfony/Component/Config/Loader/Loader.php#L70 Anyways, I've updated the algorithm to be even more verbose, to make the different situations more clear. I have little doubt there's a slicker way to write it - which should be more obvious now :). |
||
|
||
return $loader->load($resource, $type); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
load()
message can throw aFileLoaderLoadException
which should be documented in the docblock.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we add that here, even though it's a second method call that throws the exception? I'm actually not sure what the standard is here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd say we should do that here. The nested method call is a private method and people will never realise they may have to be aware of the exception if we don't document that here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
makes sense to me - I've added it - easy to remove if others don't want it