From 79e210f86c9de08329385c8d1aaa3c1761f60e0e Mon Sep 17 00:00:00 2001 From: Ryan Weaver Date: Tue, 8 Sep 2015 20:00:11 -0400 Subject: [PATCH] Using a service as a router resource --- .../Resources/config/routing.xml | 5 + src/Symfony/Component/Routing/CHANGELOG.md | 2 + .../ServiceRouterLoader.php | 41 +++++++ .../Routing/Loader/ObjectRouteLoader.php | 95 ++++++++++++++ .../Tests/Loader/ObjectRouteLoaderTest.php | 116 ++++++++++++++++++ src/Symfony/Component/Routing/composer.json | 3 +- 6 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 src/Symfony/Component/Routing/Loader/DependencyInjection/ServiceRouterLoader.php create mode 100644 src/Symfony/Component/Routing/Loader/ObjectRouteLoader.php create mode 100644 src/Symfony/Component/Routing/Tests/Loader/ObjectRouteLoaderTest.php diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/routing.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/routing.xml index a750027406ec..22aa26e2fbf4 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/routing.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/routing.xml @@ -50,6 +50,11 @@ + + + + + diff --git a/src/Symfony/Component/Routing/CHANGELOG.md b/src/Symfony/Component/Routing/CHANGELOG.md index 864ef68dda82..7f5d9f0ec029 100644 --- a/src/Symfony/Component/Routing/CHANGELOG.md +++ b/src/Symfony/Component/Routing/CHANGELOG.md @@ -5,6 +5,8 @@ CHANGELOG ----- * allowed specifying a directory to recursively load all routing configuration files it contains + * Added ObjectRouteLoader and ServiceRouteLoader that allow routes to be loaded + by calling a method on an object/service. 2.5.0 ----- diff --git a/src/Symfony/Component/Routing/Loader/DependencyInjection/ServiceRouterLoader.php b/src/Symfony/Component/Routing/Loader/DependencyInjection/ServiceRouterLoader.php new file mode 100644 index 000000000000..daa0a15dacbf --- /dev/null +++ b/src/Symfony/Component/Routing/Loader/DependencyInjection/ServiceRouterLoader.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Routing\Loader\DependencyInjection; + +use Symfony\Component\Config\Loader\Loader; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\Routing\Loader\ObjectRouteLoader; + +/** + * A route loader that executes a service to load the routes. + * + * This depends on the DependencyInjection component. + * + * @author Ryan Weaver + */ +class ServiceRouterLoader extends ObjectRouteLoader +{ + /** + * @var ContainerInterface + */ + private $container; + + public function __construct(ContainerInterface $container) + { + $this->container = $container; + } + + protected function getServiceObject($id) + { + return $this->container->get($id); + } +} diff --git a/src/Symfony/Component/Routing/Loader/ObjectRouteLoader.php b/src/Symfony/Component/Routing/Loader/ObjectRouteLoader.php new file mode 100644 index 000000000000..9dfe85a65d05 --- /dev/null +++ b/src/Symfony/Component/Routing/Loader/ObjectRouteLoader.php @@ -0,0 +1,95 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Routing\Loader; + +use Symfony\Component\Config\Loader\Loader; +use Symfony\Component\Config\Resource\FileResource; +use Symfony\Component\Routing\RouteCollection; + +/** + * A route loader that calls a method on an object to load the routes. + * + * @author Ryan Weaver + */ +abstract class ObjectRouteLoader extends Loader +{ + /** + * Returns the object that the method will be called on to load routes. + * + * For example, if your application uses a service container, + * the $id may be a service id. + * + * @param string $id + * + * @return object + */ + abstract protected function getServiceObject($id); + + /** + * Calls the service that will load the routes. + * + * @param mixed $resource Some value that will resolve to a callable + * @param string|null $type The resource type + * + * @return RouteCollection + */ + public function load($resource, $type = null) + { + $parts = explode(':', $resource); + if (count($parts) != 2) { + throw new \InvalidArgumentException(sprintf('Invalid resource "%s" passed to the "service" route loader: use the format "service_name:methodName"', $resource)); + } + + $serviceString = $parts[0]; + $method = $parts[1]; + + $loaderObject = $this->getServiceObject($serviceString); + + if (!is_object($loaderObject)) { + throw new \LogicException(sprintf('%s:getServiceObject() must return an object: %s returned', get_class($this), gettype($loaderObject))); + } + + if (!method_exists($loaderObject, $method)) { + throw new \BadMethodCallException(sprintf('Method "%s" not found on "%s" when importing routing resource "%s"', $method, get_class($loaderObject), $resource)); + } + + $routeCollection = call_user_func(array($loaderObject, $method), $this); + + if (!$routeCollection instanceof RouteCollection) { + $type = is_object($routeCollection) ? get_class($routeCollection) : gettype($routeCollection); + + throw new \LogicException(sprintf('The %s::%s method must return a RouteCollection: %s returned', get_class($loaderObject), $method, $type)); + } + + // make the service file tracked so that if it changes, the cache rebuilds + $this->addClassResource(new \ReflectionClass($loaderObject), $routeCollection); + + return $routeCollection; + } + + /** + * {@inheritdoc} + * + * @api + */ + public function supports($resource, $type = null) + { + return 'service' === $type; + } + + private function addClassResource(\ReflectionClass $class, RouteCollection $collection) + { + do { + $collection->addResource(new FileResource($class->getFileName())); + } while ($class = $class->getParentClass()); + } +} diff --git a/src/Symfony/Component/Routing/Tests/Loader/ObjectRouteLoaderTest.php b/src/Symfony/Component/Routing/Tests/Loader/ObjectRouteLoaderTest.php new file mode 100644 index 000000000000..0fbd14fa0a00 --- /dev/null +++ b/src/Symfony/Component/Routing/Tests/Loader/ObjectRouteLoaderTest.php @@ -0,0 +1,116 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Routing\Tests\Loader; + +use Symfony\Component\Routing\Loader\ObjectRouteLoader; +use Symfony\Component\Routing\Route; +use Symfony\Component\Routing\RouteCollection; + +class ObjectRouteLoaderTest extends \PHPUnit_Framework_TestCase +{ + public function testLoadCallsServiceAndReturnsCollection() + { + $loader = new ObjectRouteLoaderForTest(); + + // create a basic collection that will be returned + $collection = new RouteCollection(); + $collection->add('foo', new Route('/foo')); + + // create some callable object + $service = $this->getMockBuilder('stdClass') + ->setMethods(array('loadRoutes')) + ->getMock(); + $service->expects($this->once()) + ->method('loadRoutes') + ->with($loader) + ->will($this->returnValue($collection)); + + $loader->loaderMap = array( + 'my_route_provider_service' => $service, + ); + + $actualRoutes = $loader->load( + 'my_route_provider_service:loadRoutes', + 'service' + ); + + $this->assertSame($collection, $actualRoutes); + // the service file should be listed as a resource + $this->assertNotEmpty($actualRoutes->getResources()); + } + + /** + * @expectedException \InvalidArgumentException + * @dataProvider getBadResourceStrings + */ + public function testExceptionWithoutSyntax($resourceString) + { + $loader = new ObjectRouteLoaderForTest(); + $loader->load($resourceString); + } + + public function getBadResourceStrings() + { + return array( + array('Foo'), + array('Bar::baz'), + array('Foo:Bar:baz'), + ); + } + + /** + * @expectedException \LogicException + */ + public function testExceptionOnNoObjectReturned() + { + $loader = new ObjectRouteLoaderForTest(); + $loader->loaderMap = array('my_service' => 'NOT_AN_OBJECT'); + $loader->load('my_service:method'); + } + + /** + * @expectedException \BadMethodCallException + */ + public function testExceptionOnBadMethod() + { + $loader = new ObjectRouteLoaderForTest(); + $loader->loaderMap = array('my_service' => new \stdClass()); + $loader->load('my_service:method'); + } + + /** + * @expectedException \LogicException + */ + public function testExceptionOnMethodNotReturningCollection() + { + $service = $this->getMockBuilder('stdClass') + ->setMethods(array('loadRoutes')) + ->getMock(); + $service->expects($this->once()) + ->method('loadRoutes') + ->will($this->returnValue('NOT_A_COLLECTION')); + + $loader = new ObjectRouteLoaderForTest(); + $loader->loaderMap = array('my_service' => $service); + $loader->load('my_service:loadRoutes'); + } +} + +class ObjectRouteLoaderForTest extends ObjectRouteLoader +{ + public $loaderMap = array(); + + protected function getServiceObject($id) + { + return isset($this->loaderMap[$id]) ? $this->loaderMap[$id] : null; + } +} diff --git a/src/Symfony/Component/Routing/composer.json b/src/Symfony/Component/Routing/composer.json index b7d4c0aa2bdb..3fad06e80612 100644 --- a/src/Symfony/Component/Routing/composer.json +++ b/src/Symfony/Component/Routing/composer.json @@ -35,7 +35,8 @@ "symfony/config": "For using the all-in-one router or any loader", "symfony/yaml": "For using the YAML loader", "symfony/expression-language": "For using expression matching", - "doctrine/annotations": "For using the annotation loader" + "doctrine/annotations": "For using the annotation loader", + "symfony/dependency-injection": "For loading routes from a service" }, "autoload": { "psr-4": { "Symfony\\Component\\Routing\\": "" }