Skip to content

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

Closed
wants to merge 34 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
51f60fc
Adding a new framework-specific Route class
weaverryan Sep 9, 2015
6891ec8
Adding a class to make adding/importing routes easier and more fluid
weaverryan Sep 9, 2015
4e430a0
Maintaining all the RouteCollection abilities to RouteCollectionBuilder
weaverryan Sep 12, 2015
0ddadc5
Moving the prefix to the builder, so that it's consistent with other …
weaverryan Sep 13, 2015
06ea900
Adding phpdoc
weaverryan Sep 13, 2015
6b922a6
Using InvalidArgumentException
weaverryan Sep 13, 2015
14518ed
No change - renaming variable
weaverryan Sep 13, 2015
7972fc9
Adding many more tests, which included a few small bug fixes with val…
weaverryan Sep 13, 2015
e11b7e0
Removed prefix argument from mount() - and added it instead to create…
weaverryan Sep 13, 2015
4d90916
fabbot!
weaverryan Sep 14, 2015
729ccbb
Renaming flush() to build()
weaverryan Sep 15, 2015
e509953
Not clearing everything on build - unnecessary, and the RouteCollecti…
weaverryan Sep 15, 2015
01e1329
Fixing phpdoc
weaverryan Sep 15, 2015
e39e0c4
Removing the FrameworkBundle Route and the ability to call Route::set…
weaverryan Sep 16, 2015
df1849f
Simplifying by transforming RouteCollection's into RouteCollectionBui…
weaverryan Sep 16, 2015
97b1eea
Fixing a bug with knowing which keys should be auto-generated
weaverryan Sep 16, 2015
e1ecde4
Minor code improvement to centralize things
weaverryan Sep 16, 2015
ecf4346
Renaming methods for clarity and consistency
weaverryan Sep 16, 2015
0dce55d
fabbot and possible test fixes
weaverryan Sep 16, 2015
f3d71ad
Allowing LoaderInterface instead of Loader
weaverryan Sep 17, 2015
8132fcb
Updating setRequirements to avoid deprecated calls
weaverryan Sep 17, 2015
b2676ec
phpdoc typo
weaverryan Sep 26, 2015
bf6790b
Making RouteCollectionBuilder's LoaderInteface optional
weaverryan Sep 30, 2015
61e4bf7
removing extra spaces
weaverryan Sep 30, 2015
8f0b956
moving into the component
weaverryan Sep 30, 2015
fbab6d4
Removing the ability to set a prefix on a builder: that only happens …
weaverryan Sep 30, 2015
012cb92
Removing the prefix from import, and making the user actually put tha…
weaverryan Sep 30, 2015
33255dd
Fixing a bug with route name collission because the prefix wasn't acc…
weaverryan Sep 30, 2015
356b114
Refactoring into private method
weaverryan Sep 30, 2015
7cb8996
fabbot
weaverryan Sep 30, 2015
573a7f1
Small tweaks suggested by fabpot, including removal of addResource(),…
weaverryan Sep 30, 2015
83f3194
stretching out logic into multiple lines: there's some discussion abo…
weaverryan Sep 30, 2015
26d656b
Fabbot
weaverryan Sep 30, 2015
42e73a2
Adding throws
weaverryan Oct 1, 2015
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
348 changes: 348 additions & 0 deletions src/Symfony/Component/Routing/RouteCollectionBuilder.php
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);
Copy link
Member

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 a FileLoaderLoadException which should be documented in the docblock.

Copy link
Member Author

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.

Copy link
Member

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.

Copy link
Member Author

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


// 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
Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name is disturbing, you don not expect a Route. I think add should do what addRoute does (just adding a route to the collection), and then this one, like in the ContainerBuilder should be named register or something like that ? cf https://github.com/symfony/symfony/blob/2.8/src/Symfony/Component/DependencyInjection/ContainerBuilder.php#L728

Copy link
Member Author

Choose a reason for hiding this comment

The 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 addRoute(). If we called this register(), I think people would be looking for a method like add() or addRoute(), and might try calling addRoute() instead.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It also makes sense, as there is no set method in the ContainerBuilder too (it's directly setDefinition)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When looking at the code written with this builder, add() looks much better and I think it's quite intuitive.

*/
public function add($path, $controller, $name = null)
{
$route = new Route($path);
$route->setDefault('_controller', $controller);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The routing component doesn't know anything about a _controller. So this seems misplaced. Such assumption can only be made in the framework bundle.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Tobion you're right that the Routing component doesn't know about _controller. But HttpKernel does know about this, and I wanted this to be available for things like Drupal. Since there is no bridge between HttpKernel and Routing, I put it here, which purposefully makes this class useable only by people who follow this convention. It's not perfect, but it's the best place. Of course, if you wanted to use this in another system, you could override this method.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If HttpKernel does, why not putting it in HttpKernel instead?

Copy link
Member Author

Choose a reason for hiding this comment

The 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 :)

Copy link
Member

Choose a reason for hiding this comment

The 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, ...)

Copy link
Member Author

Choose a reason for hiding this comment

The 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.
Copy link
Contributor

Choose a reason for hiding this comment

The 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)
Copy link
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Member Author

Choose a reason for hiding this comment

The 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);
}
Copy link
Member

Choose a reason for hiding this comment

The 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 false to detect a special condition, which seems very hacky.

Copy link
Member Author

Choose a reason for hiding this comment

The 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);
}
}
Loading
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy