From a64cb651002efbc441ebac8ece25c1afccbda2af Mon Sep 17 00:00:00 2001 From: Gunnar Lium Date: Sun, 8 Jan 2012 00:57:15 +0100 Subject: [PATCH] [Routing] added support for hostname requirement to routes --- .../Exception/HostnameNotAllowedException.php | 37 +++++++++++++++++++ .../InvalidHostnameParameterException.php | 23 ++++++++++++ .../Routing/Generator/UrlGenerator.php | 27 +++++++++++++- .../Matcher/Dumper/PhpMatcherDumper.php | 34 +++++++++++++++-- .../Component/Routing/Matcher/UrlMatcher.php | 22 +++++++++-- .../Tests/Fixtures/dumper/url_matcher1.php | 17 ++++++++- .../Tests/Fixtures/dumper/url_matcher2.php | 17 ++++++++- .../Tests/Generator/UrlGeneratorTest.php | 32 ++++++++++++++++ .../Matcher/Dumper/PhpMatcherDumperTest.php | 6 +++ .../Routing/Tests/Matcher/UrlMatcherTest.php | 37 +++++++++++++++++++ 10 files changed, 243 insertions(+), 9 deletions(-) create mode 100644 src/Symfony/Component/Routing/Exception/HostnameNotAllowedException.php create mode 100644 src/Symfony/Component/Routing/Exception/InvalidHostnameParameterException.php diff --git a/src/Symfony/Component/Routing/Exception/HostnameNotAllowedException.php b/src/Symfony/Component/Routing/Exception/HostnameNotAllowedException.php new file mode 100644 index 0000000000000..0bdb9d39b189d --- /dev/null +++ b/src/Symfony/Component/Routing/Exception/HostnameNotAllowedException.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Routing\Exception; + +/** + * The resource was found but the hostname is not allowed. + * + * This exception should trigger an HTTP 404 response in your application code. + * + * @author Gunnar Lium + * + */ +class HostnameNotAllowedException extends \RuntimeException implements ExceptionInterface +{ + protected $allowedHostnames; + + public function __construct(array $allowedHostnames, $message = null, $code = 0, \Exception $previous = null) + { + $this->allowedHostnames = array_map('strtolower', $allowedHostnames); + + parent::__construct($message, $code, $previous); + } + + public function getAllowedHostnames() + { + return $this->allowedHostnames; + } +} diff --git a/src/Symfony/Component/Routing/Exception/InvalidHostnameParameterException.php b/src/Symfony/Component/Routing/Exception/InvalidHostnameParameterException.php new file mode 100644 index 0000000000000..3bade34604771 --- /dev/null +++ b/src/Symfony/Component/Routing/Exception/InvalidHostnameParameterException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Routing\Exception; + +/** + * Exception thrown when hostname parameter doesn't match hostname from requirements + * + * @author Gunnar Lium + * + * @api + */ +class InvalidHostnameParameterException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Routing/Generator/UrlGenerator.php b/src/Symfony/Component/Routing/Generator/UrlGenerator.php index 535f7bb870e5c..60d4e5ea1eef3 100644 --- a/src/Symfony/Component/Routing/Generator/UrlGenerator.php +++ b/src/Symfony/Component/Routing/Generator/UrlGenerator.php @@ -15,6 +15,7 @@ use Symfony\Component\Routing\RouteCollection; use Symfony\Component\Routing\RequestContext; use Symfony\Component\Routing\Exception\InvalidParameterException; +use Symfony\Component\Routing\Exception\InvalidHostnameParameterException; use Symfony\Component\Routing\Exception\RouteNotFoundException; use Symfony\Component\Routing\Exception\MissingMandatoryParametersException; @@ -99,6 +100,11 @@ protected function doGenerate($variables, $defaults, $requirements, $tokens, $pa { $variables = array_flip($variables); + $preferredHost = null; + if (isset($parameters['_host'])) { + $preferredHost = $parameters['_host']; + unset($parameters['_host']); + } $originParameters = $parameters; $parameters = array_replace($this->context->getParameters(), $parameters); $tparams = array_replace($defaults, $parameters); @@ -151,6 +157,25 @@ protected function doGenerate($variables, $defaults, $requirements, $tokens, $pa $scheme = $req; } + $host = $this->context->getHost(); + if (isset($requirements['_host']) && ($req = strtolower($requirements['_host'])) && $host != $req) { + $hosts = explode('|', $req); + $absolute = true; + if (1 === count($hosts)) { + $host = $req; + } else { + if ($preferredHost) { + if (in_array($preferredHost, $hosts)) { + $host = $preferredHost; + } else { + throw new InvalidHostnameParameterException(sprintf('Preferred hostname for route "%s" must match "%s" ("%s" given).', $name, $req, $preferredHost)); + } + } elseif (!in_array($host, $hosts)) { + $host = $hosts[0]; + } + } + } + if ($absolute) { $port = ''; if ('http' === $scheme && 80 != $this->context->getHttpPort()) { @@ -159,7 +184,7 @@ protected function doGenerate($variables, $defaults, $requirements, $tokens, $pa $port = ':'.$this->context->getHttpsPort(); } - $url = $scheme.'://'.$this->context->getHost().$port.$url; + $url = $scheme.'://'.$host.$port.$url; } } diff --git a/src/Symfony/Component/Routing/Matcher/Dumper/PhpMatcherDumper.php b/src/Symfony/Component/Routing/Matcher/Dumper/PhpMatcherDumper.php index 9df017640793d..d6e397b9b7db6 100644 --- a/src/Symfony/Component/Routing/Matcher/Dumper/PhpMatcherDumper.php +++ b/src/Symfony/Component/Routing/Matcher/Dumper/PhpMatcherDumper.php @@ -62,11 +62,14 @@ private function addMatcher($supportsRedirections) public function match(\$pathinfo) { \$allow = array(); + \$hosts = array(); \$pathinfo = urldecode(\$pathinfo); $code - throw 0 < count(\$allow) ? new MethodNotAllowedException(array_unique(\$allow)) : new ResourceNotFoundException(); + throw 0 < count(\$allow) ? new MethodNotAllowedException(array_unique(\$allow)) : + 0 < count(\$hosts) ? new HostnameNotAllowedException(array_unique(\$hosts)) : + new ResourceNotFoundException(); } EOF; @@ -148,7 +151,7 @@ private function compileRoute(Route $route, $name, $supportsRedirections, $paren $hasTrailingSlash = false; $matches = false; $methods = array(); - + $hostnames = array(); if ($req = $route->getRequirement('_method')) { $methods = explode('|', strtoupper($req)); // GET and HEAD are equivalent @@ -156,6 +159,9 @@ private function compileRoute(Route $route, $name, $supportsRedirections, $paren $methods[] = 'HEAD'; } } + if ($req = $route->getRequirement('_host')) { + $hostnames = explode('|', strtolower($req)); + } $supportsTrailingSlash = $supportsRedirections && (!$methods || in_array('HEAD', $methods)); @@ -208,6 +214,27 @@ private function compileRoute(Route $route, $name, $supportsRedirections, $paren goto $gotoname; } +EOF; + } + } + + if ($hostnames) { + $gotoname = 'not_'.preg_replace('/[^A-Za-z0-9_]/', '', $name); + if (1 === count($hostnames)) { + $code .= <<context->getHost() != '$hostnames[0]') { + \$hosts[] = '$hostnames[0]'; + goto $gotoname; + } +EOF; + } else { + $hostnames = implode('\', \'', $hostnames); + $code .= <<context->getHost(), array('$hostnames'))) { + \$hosts = array_merge(\$hosts, array('$hostnames')); + goto $gotoname; + } + EOF; } } @@ -248,7 +275,7 @@ private function compileRoute(Route $route, $name, $supportsRedirections, $paren } $code .= " }\n"; - if ($methods) { + if ($methods || $hostnames) { $code .= " $gotoname:\n"; } @@ -261,6 +288,7 @@ private function startClass($class, $baseClass) allow = array(); + $this->hosts = array(); if ($ret = $this->matchCollection(urldecode($pathinfo), $this->routes)) { return $ret; } - throw 0 < count($this->allow) - ? new MethodNotAllowedException(array_unique(array_map('strtoupper', $this->allow))) - : new ResourceNotFoundException(); + if (0 < count($this->allow)) { + throw new MethodNotAllowedException(array_unique(array_map('strtoupper', $this->allow))); + } + if (0 < count($this->hosts)) { + throw new HostnameNotAllowedException(array_unique(array_map('strtolower', $this->hosts))); + } + throw new ResourceNotFoundException(); } /** @@ -150,6 +156,16 @@ protected function matchCollection($pathinfo, RouteCollection $routes) continue; } + // check hostname requirement + if ($req = $route->getRequirement('_host')) { + $host = $this->context->getHost(); + if (!in_array($host, $req = explode('|', $req))) { + $this->hosts = array_merge($this->hosts, $req); + + continue; + } + } + return array_merge($this->mergeDefaults($matches, $route->getDefaults()), array('_route' => $name)); } } diff --git a/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher1.php b/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher1.php index 6367119def646..61d595e289b07 100644 --- a/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher1.php +++ b/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher1.php @@ -1,6 +1,7 @@ [^/]+?)$#xs', $pathinfo, $matches)) { + if (!in_array($this->context->getHost(), array('symfony.com', 'symfony.org'))) { + $hosts = array_merge($hosts, array('symfony.com', 'symfony.org')); + goto not_barhost; + } + $matches['_route'] = 'barhost'; + return $matches; + } + not_barhost: + // baz if ($pathinfo === '/test/baz') { return array('_route' => 'baz'); @@ -162,6 +175,8 @@ public function match($pathinfo) return $matches; } - throw 0 < count($allow) ? new MethodNotAllowedException(array_unique($allow)) : new ResourceNotFoundException(); + throw 0 < count($allow) ? new MethodNotAllowedException(array_unique($allow)) : + 0 < count($hosts) ? new HostnameNotAllowedException(array_unique($hosts)) : + new ResourceNotFoundException(); } } diff --git a/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher2.php b/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher2.php index 7a4d5b12a234c..26e2a4e1bb6ca 100644 --- a/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher2.php +++ b/src/Symfony/Component/Routing/Tests/Fixtures/dumper/url_matcher2.php @@ -1,6 +1,7 @@ [^/]+?)$#xs', $pathinfo, $matches)) { + if (!in_array($this->context->getHost(), array('symfony.com', 'symfony.org'))) { + $hosts = array_merge($hosts, array('symfony.com', 'symfony.org')); + goto not_barhost; + } + $matches['_route'] = 'barhost'; + return $matches; + } + not_barhost: + // baz if ($pathinfo === '/test/baz') { return array('_route' => 'baz'); @@ -184,6 +197,8 @@ public function match($pathinfo) return array('_route' => 'nonsecure'); } - throw 0 < count($allow) ? new MethodNotAllowedException(array_unique($allow)) : new ResourceNotFoundException(); + throw 0 < count($allow) ? new MethodNotAllowedException(array_unique($allow)) : + 0 < count($hosts) ? new HostnameNotAllowedException(array_unique($hosts)) : + new ResourceNotFoundException(); } } diff --git a/src/Symfony/Component/Routing/Tests/Generator/UrlGeneratorTest.php b/src/Symfony/Component/Routing/Tests/Generator/UrlGeneratorTest.php index e4834e55110b3..5e15508dc57b8 100644 --- a/src/Symfony/Component/Routing/Tests/Generator/UrlGeneratorTest.php +++ b/src/Symfony/Component/Routing/Tests/Generator/UrlGeneratorTest.php @@ -50,6 +50,38 @@ public function testAbsoluteSecureUrlWithNonStandardPort() $this->assertEquals('https://localhost:8080/app.php/testing', $url); } + public function testAbsoluteUrlWithOneHostnameAddsHostname() + { + $routes = $this->getRoutes('test', new Route('/hostname', array(), array('_host' => 'symfony.com'))); + $url = $this->getGenerator($routes)->generate('test', array(), true); + + $this->assertEquals('http://symfony.com/app.php/hostname', $url); + } + + public function testAbsoluteUrlWithMultipleHostnamesPicksFirstHostnameIfHostnameNotInContext() + { + $routes = $this->getRoutes('test', new Route('/hostname', array(), array('_host' => 'symfony.com|symfony.org'))); + $url = $this->getGenerator($routes, array('host' => 'symfony.net'))->generate('test', array(), true); + + $this->assertEquals('http://symfony.com/app.php/hostname', $url); + } + + public function testAbsoluteUrlWithMultipleHostnamesPicksHostnameFromContextIfAvailable() + { + $routes = $this->getRoutes('test', new Route('/hostname', array(), array('_host' => 'symfony.com|symfony.org'))); + $url = $this->getGenerator($routes, array('host' => 'symfony.org'))->generate('test', array(), true); + + $this->assertEquals('http://symfony.org/app.php/hostname', $url); + } + + public function testAbsoluteUrlWithMultipleHostnamesAndSpecifiedHostUsesSpecified() + { + $routes = $this->getRoutes('test', new Route('/hostname', array(), array('_host' => 'symfony.com|symfony.org'))); + $url = $this->getGenerator($routes)->generate('test', array('_host' => 'symfony.org'), true); + + $this->assertEquals('http://symfony.org/app.php/hostname', $url); + } + public function testRelativeUrlWithoutParameters() { $routes = $this->getRoutes('test', new Route('/testing')); diff --git a/src/Symfony/Component/Routing/Tests/Matcher/Dumper/PhpMatcherDumperTest.php b/src/Symfony/Component/Routing/Tests/Matcher/Dumper/PhpMatcherDumperTest.php index aa0b05ca0b871..4cd938b603f8a 100644 --- a/src/Symfony/Component/Routing/Tests/Matcher/Dumper/PhpMatcherDumperTest.php +++ b/src/Symfony/Component/Routing/Tests/Matcher/Dumper/PhpMatcherDumperTest.php @@ -84,6 +84,12 @@ protected function getRouteCollection() array(), array('_method' => 'GET') )); + // hostname requirement + $collection->add('barhost', new Route( + '/barhost/{foo}', + array(), + array('_host' => 'symfony.com|symfony.org') + )); // simple $collection->add('baz', new Route( '/test/baz' diff --git a/src/Symfony/Component/Routing/Tests/Matcher/UrlMatcherTest.php b/src/Symfony/Component/Routing/Tests/Matcher/UrlMatcherTest.php index 14db7171cbaa3..7f27c93985c85 100644 --- a/src/Symfony/Component/Routing/Tests/Matcher/UrlMatcherTest.php +++ b/src/Symfony/Component/Routing/Tests/Matcher/UrlMatcherTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Routing\Tests\Matcher; use Symfony\Component\Routing\Exception\MethodNotAllowedException; +use Symfony\Component\Routing\Exception\HostnameNotAllowedException; use Symfony\Component\Routing\Exception\ResourceNotFoundException; use Symfony\Component\Routing\Matcher\UrlMatcher; use Symfony\Component\Routing\Route; @@ -44,6 +45,21 @@ public function testMethodNotAllowed() } } + public function testHostnameNotAllowed() + { + $coll = new RouteCollection(); + $coll->add('foo', new Route('/foo', array(), array('_host' => 'symfony.com'))); + + $matcher = new UrlMatcher($coll, new RequestContext()); + + try { + $matcher->match('/foo'); + $this->fail(); + } catch (HostnameNotAllowedException $e) { + $this->assertEquals(array('symfony.com'), $e->getAllowedHostnames()); + } + } + public function testHeadAllowedWhenRequirementContainsGet() { $coll = new RouteCollection(); @@ -104,6 +120,27 @@ public function testMatch() $matcher = new UrlMatcher($collection, new RequestContext(), array()); $this->assertInternalType('array', $matcher->match('/foo')); $matcher = new UrlMatcher($collection, new RequestContext('', 'head'), array()); + + // test that route only matches when valid hostname is provided + $collection = new RouteCollection(); + $collection->add('foo', new Route('/foo', array(), array('_host' => 'symfony.com'))); + + // route does not match if no hostname is provided + $matcher = new UrlMatcher($collection, new RequestContext(), array()); + try { + $matcher->match('/foo'); + $this->fail(); + } catch (HostnameNotAllowedException $e) {} + + // route does not match if for wrong hostname + $matcher = new UrlMatcher($collection, new RequestContext('', 'head', 'symfony.org'), array()); + try { + $matcher->match('/foo'); + $this->fail(); + } catch (HostnameNotAllowedException $e) {} + + // route does match if correct hostname is provided + $matcher = new UrlMatcher($collection, new RequestContext('', 'head', 'symfony.com'), array()); $this->assertInternalType('array', $matcher->match('/foo')); // route with an optional variable as the first segment