diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/RouterApacheDumperCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/RouterApacheDumperCommand.php index ecbb5a9509901..9e14cf08c20eb 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/RouterApacheDumperCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/RouterApacheDumperCommand.php @@ -32,7 +32,8 @@ protected function configure() { $this ->setDefinition(array( - new InputArgument('script_name', InputArgument::OPTIONAL, 'The script name of the application\'s front controller.') + new InputArgument('script_name', InputArgument::OPTIONAL, 'The script name of the application\'s front controller.'), + new InputOption('base-uri', null, InputOption::VALUE_REQUIRED, 'The base URI'), )) ->setName('router:dump-apache') ->setDescription('Dumps all routes as Apache rewrite rules') @@ -58,6 +59,9 @@ protected function execute(InputInterface $input, OutputInterface $output) if ($input->getArgument('script_name')) { $dumpOptions['script_name'] = $input->getArgument('script_name'); } + if ($input->getOption('base-uri')) { + $dumpOptions['base_uri'] = $input->getOption('base-uri'); + } $dumper = new ApacheMatcherDumper($router->getRouteCollection()); diff --git a/src/Symfony/Bundle/FrameworkBundle/Controller/ExceptionController.php b/src/Symfony/Bundle/FrameworkBundle/Controller/ExceptionController.php index eaa5e7c675722..f021f12005204 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Controller/ExceptionController.php +++ b/src/Symfony/Bundle/FrameworkBundle/Controller/ExceptionController.php @@ -29,10 +29,13 @@ class ExceptionController extends ContainerAware * @param FlattenException $exception A FlattenException instance * @param DebugLoggerInterface $logger A DebugLoggerInterface instance * @param string $format The format to use for rendering (html, xml, ...) + * @param integer $code An HTTP response code + * @param string $message An HTTP response status message + * @param array $headers HTTP response headers * * @throws \InvalidArgumentException When the exception template does not exist */ - public function showAction(FlattenException $exception, DebugLoggerInterface $logger = null, $format = 'html') + public function showAction(FlattenException $exception, DebugLoggerInterface $logger = null, $format = 'html', $code = 500, $message = null, array $headers = array()) { $this->container->get('request')->setRequestFormat($format); @@ -45,8 +48,6 @@ public function showAction(FlattenException $exception, DebugLoggerInterface $lo $currentContent .= ob_get_clean(); } - $code = $this->getStatusCode($exception); - $name = $this->container->get('kernel')->isDebug() ? 'exception' : 'error'; if ($this->container->get('kernel')->isDebug() && 'html' == $format) { $name = 'exception_full'; @@ -63,27 +64,16 @@ public function showAction(FlattenException $exception, DebugLoggerInterface $lo $template, array( 'status_code' => $code, - 'status_text' => Response::$statusTexts[$code], + 'status_text' => $message ?: Response::$statusTexts[$code], 'exception' => $exception, 'logger' => $logger, 'currentContent' => $currentContent, ) ); - $response->setStatusCode($code); + $response->setStatusCode($code, $message); + $response->headers->replace($headers); return $response; } - - protected function getStatusCode(FlattenException $exception) - { - switch ($exception->getClass()) { - case 'Symfony\Component\Security\Core\Exception\AccessDeniedException': - return 403; - case 'Symfony\Component\HttpKernel\Exception\NotFoundHttpException': - return 404; - default: - return 500; - } - } } diff --git a/src/Symfony/Bundle/FrameworkBundle/RequestListener.php b/src/Symfony/Bundle/FrameworkBundle/RequestListener.php index 9d50c3206583f..ba5f1d95efe93 100644 --- a/src/Symfony/Bundle/FrameworkBundle/RequestListener.php +++ b/src/Symfony/Bundle/FrameworkBundle/RequestListener.php @@ -14,9 +14,13 @@ use Symfony\Component\HttpKernel\Log\LoggerInterface; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\HttpKernel\Event\GetResponseEvent; +use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\Routing\RouterInterface; use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\Routing\Matcher\Exception\MethodNotAllowedException; +use Symfony\Component\Routing\Matcher\Exception\NotFoundException; +use Symfony\Component\Routing\RouterInterface; /** * RequestListener. @@ -83,7 +87,9 @@ protected function initializeRequestAttributes(Request $request, $master) } // add attributes based on the path info (routing) - if (false !== $parameters = $this->router->match($request->getPathInfo())) { + try { + $parameters = $this->router->match($request->getPathInfo()); + if (null !== $this->logger) { $this->logger->info(sprintf('Matched route "%s" (parameters: %s)', $parameters['_route'], json_encode($parameters))); } @@ -93,8 +99,18 @@ protected function initializeRequestAttributes(Request $request, $master) if ($locale = $request->attributes->get('_locale')) { $request->getSession()->setLocale($locale); } - } elseif (null !== $this->logger) { - $this->logger->err(sprintf('No route found for %s', $request->getPathInfo())); + } catch (NotFoundException $e) { + $message = sprintf('No route found for "%s %s"', $request->getMethod(), $request->getPathInfo()); + if (null !== $this->logger) { + $this->logger->err($message); + } + throw new NotFoundHttpException('Not Found', $message, 0, $e); + } catch (MethodNotAllowedException $e) { + $message = sprintf('No route found for "%s %s": Method Not Allowed (Allow: %s)', $request->getMethod(), $request->getPathInfo(), strtoupper(implode(', ', $e->getAllowedMethods()))); + if (null !== $this->logger) { + $this->logger->err($message); + } + throw new MethodNotAllowedHttpException($e->getAllowedMethods(), 'Method Not Allowed', $message, 0, $e); } } } diff --git a/src/Symfony/Component/HttpKernel/Debug/ExceptionListener.php b/src/Symfony/Component/HttpKernel/Debug/ExceptionListener.php index d2e5fe52eec06..06a6befe50c27 100644 --- a/src/Symfony/Component/HttpKernel/Debug/ExceptionListener.php +++ b/src/Symfony/Component/HttpKernel/Debug/ExceptionListener.php @@ -16,6 +16,7 @@ use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\HttpKernel\Exception\FlattenException; +use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Symfony\Component\HttpFoundation\Request; /** @@ -63,6 +64,16 @@ public function onCoreException(GetResponseForExceptionEvent $event) 'format' => 0 === strncasecmp(PHP_SAPI, 'cli', 3) ? 'txt' : $request->getRequestFormat(), ); + $attributes += $exception instanceof HttpExceptionInterface ? array( + 'code' => $exception->getStatusCode(), + 'message' => $exception->getStatusMessage(), + 'headers' => $exception->getHeaders(), + ) : array( + 'code' => 500, + 'message' => 'Internal Server Error', + 'headers' => array(), + ); + $request = $request->duplicate(null, null, $attributes); try { diff --git a/src/Symfony/Component/HttpKernel/Exception/BaseHttpException.php b/src/Symfony/Component/HttpKernel/Exception/BaseHttpException.php new file mode 100644 index 0000000000000..2a3150577625b --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Exception/BaseHttpException.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Exception; + +/** + * BaseHttpException. + * + * @author Kris Wallsmith + */ +abstract class BaseHttpException extends \RuntimeException implements HttpExceptionInterface +{ + protected $statusCode; + protected $statusMessage; + protected $headers; + + public function __construct($statusCode, $statusMessage, array $headers = array(), $message = null, $code = 0, \Exception $previous = null) + { + $this->statusCode = $statusCode; + $this->statusMessage = $statusMessage; + $this->headers = $headers; + + parent::__construct($message, 0, $previous); + } + + public function getStatusCode() + { + return $this->statusCode; + } + + public function getStatusMessage() + { + return $this->statusMessage; + } + + public function getHeaders() + { + return $this->headers; + } +} diff --git a/src/Symfony/Component/HttpKernel/Exception/HttpExceptionInterface.php b/src/Symfony/Component/HttpKernel/Exception/HttpExceptionInterface.php new file mode 100644 index 0000000000000..6e09bd7244660 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Exception/HttpExceptionInterface.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\HttpKernel\Exception; + +/** + * Interface for HTTP error exceptions. + * + * @author Kris Wallsmith + */ +interface HttpExceptionInterface +{ + /** + * Returns the status code. + * + * @return integer An HTTP response status code + */ + function getStatusCode(); + + /** + * Return the status message. + * + * @return string An HTTP response status message + */ + function getStatusMessage(); + + /** + * Returns response headers. + * + * @return array Response headers + */ + function getHeaders(); +} diff --git a/src/Symfony/Component/HttpKernel/Exception/MethodNotAllowedHttpException.php b/src/Symfony/Component/HttpKernel/Exception/MethodNotAllowedHttpException.php new file mode 100644 index 0000000000000..34da11d181ce6 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Exception/MethodNotAllowedHttpException.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Exception; + +/** + * MethodNotAllowedHttpException. + * + * @author Kris Wallsmith + */ +class MethodNotAllowedHttpException extends BaseHttpException +{ + /** + * Constructor. + * + * WARNING: The status message will be sent as a response header + * regardless of debug mode. + * + * @param array $allow An array of allowed methods + * @param string $statusMessage The HTTP response status message + * @param string $message The internal exception message + * @param integer $code The internal exception code + * @param Exception $previous The previous exception + */ + public function __construct(array $allow, $statusMessage = 'Method Not Allowed', $message = null, $code = 0, \Exception $previous = null) + { + $headers = array('Allow' => strtoupper(implode(', ', $allow))); + + parent::__construct(405, $statusMessage, $headers, $message ?: $statusMessage, $code, $previous); + } +} diff --git a/src/Symfony/Component/HttpKernel/Exception/NotFoundHttpException.php b/src/Symfony/Component/HttpKernel/Exception/NotFoundHttpException.php index f7dfe71ede074..2cddc931ffd54 100644 --- a/src/Symfony/Component/HttpKernel/Exception/NotFoundHttpException.php +++ b/src/Symfony/Component/HttpKernel/Exception/NotFoundHttpException.php @@ -16,10 +16,21 @@ * * @author Fabien Potencier */ -class NotFoundHttpException extends \RuntimeException +class NotFoundHttpException extends BaseHttpException { - public function __construct($message = 'Not Found', \Exception $previous = null) + /** + * Constructor. + * + * WARNING: The status message will be sent as a response header + * regardless of debug mode. + * + * @param string $statusMessage The HTTP response status message + * @param string $message The internal exception message + * @param integer $code The internal exception code + * @param Exception $previous The previous exception + */ + public function __construct($statusMessage = 'Not Found', $message = null, $code = 0, \Exception $previous = null) { - parent::__construct($message, 404, $previous); + parent::__construct(404, $statusMessage, array(), $message ?: $statusMessage, $code, $previous); } } diff --git a/src/Symfony/Component/Routing/Matcher/ApacheUrlMatcher.php b/src/Symfony/Component/Routing/Matcher/ApacheUrlMatcher.php index 9525bbaf449d0..71252c81b1140 100644 --- a/src/Symfony/Component/Routing/Matcher/ApacheUrlMatcher.php +++ b/src/Symfony/Component/Routing/Matcher/ApacheUrlMatcher.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Routing\Matcher; +use Symfony\Component\Routing\Matcher\Exception\MethodNotAllowedException; use Symfony\Component\Routing\Route; use Symfony\Component\Routing\RouteCollection; @@ -21,55 +22,53 @@ */ class ApacheUrlMatcher extends UrlMatcher { - protected $defaults; - protected $context; - - /** - * Constructor. - * - * @param array $context The context - * @param array $defaults The default values - */ - public function __construct(array $context = array(), array $defaults = array()) - { - $this->context = $context; - $this->defaults = $defaults; - } - - /** - * Sets the request context. - * - * @param array $context The context - */ - public function setContext(array $context = array()) - { - $this->context = $context; - } - /** * Tries to match a URL based on Apache mod_rewrite matching. * * Returns false if no route matches the URL. * - * @param string $pathinfo The pathinfo to be parsed + * @param string $pathinfo The pathinfo to be parsed * - * @return array|false An array of parameters or false if no route matches + * @return array An array of parameters + * + * @throws MethodNotAllowedException If the current method is not allowed */ public function match($pathinfo) { - if (!isset($_SERVER['_ROUTING__route'])) { - // fall-back to the default UrlMatcher - return parent::match($pathinfo); - } - $parameters = array(); + $allow = array(); + $match = false; + foreach ($_SERVER as $key => $value) { - if ('_ROUTING_' === substr($key, 0, 9)) { - $parameters[substr($key, 9)] = $value; - unset($_SERVER[$key]); + $name = $key; + + if (0 === strpos($name, 'REDIRECT_')) { + $name = substr($name, 9); } + + if (0 === strpos($name, '_ROUTING_')) { + $name = substr($name, 9); + } else { + continue; + } + + if ('_route' == $name) { + $match = true; + } elseif (0 === strpos($name, '_allow_')) { + $allow[] = substr($name, 7); + } else { + $parameters[$name] = $value; + } + + unset($_SERVER[$key]); } - return $parameters; + if ($match) { + return $parameters; + } elseif (0 < count($allow)) { + throw new MethodNotAllowedException($allow); + } else { + return parent::match($pathinfo); + } } } diff --git a/src/Symfony/Component/Routing/Matcher/Dumper/ApacheMatcherDumper.php b/src/Symfony/Component/Routing/Matcher/Dumper/ApacheMatcherDumper.php index 1609fe8bbd7d4..4584e8ed87c93 100644 --- a/src/Symfony/Component/Routing/Matcher/Dumper/ApacheMatcherDumper.php +++ b/src/Symfony/Component/Routing/Matcher/Dumper/ApacheMatcherDumper.php @@ -14,67 +14,96 @@ use Symfony\Component\Routing\Route; /** - * ApacheMatcherDumper dumps a matcher in the Apache .htaccess format. + * Dumps a set of Apache mod_rewrite rules. * * @author Fabien Potencier + * @author Kris Wallsmith */ class ApacheMatcherDumper extends MatcherDumper { /** - * Dumps a set of routes to a .htaccess format. + * Dumps a set of Apache mod_rewrite rules. * * Available options: * * * script_name: The script name (app.php by default) + * * base_uri: The base URI ("" by default) * - * @param array $options An array of options + * @param array $options An array of options * - * @return string A string to be used as Apache rewrite rules. - * - * @throws \RuntimeException When a route has more than 9 variables + * @return string A string to be used as Apache rewrite rules */ public function dump(array $options = array()) { $options = array_merge(array( 'script_name' => 'app.php', + 'base_uri' => '', ), $options); - $regexes = array(); + $rules = array("# skip \"real\" requests\nRewriteCond %{REQUEST_FILENAME} -f\nRewriteRule .* - [QSA,L]"); + $methodVars = array(); foreach ($this->routes->all() as $name => $route) { $compiledRoute = $route->compile(); - // Apache "only" supports 9 variables - if (count($compiledRoute->getVariables()) > 9) { - throw new \RuntimeException(sprintf('Unable to dump a route collection as route "%s" has more than 9 variables', $name)); - } - + // prepare the apache regex $regex = preg_replace('/\?P<.+?>/', '', substr($compiledRoute->getRegex(), 1, -2)); + $regex = '^'.preg_quote($options['base_uri']).substr($regex, 1); + + $hasTrailingSlash = '/$' == substr($regex, -2) && '^/$' != $regex; $variables = array('E=_ROUTING__route:'.$name); foreach (array_keys($compiledRoute->getVariables()) as $i => $variable) { $variables[] = 'E=_ROUTING_'.$variable.':%'.($i + 1); } foreach ($route->getDefaults() as $key => $value) { - $variables[] = 'E=_ROUTING_'.$key.':'.$value; + // todo: a more legit way to escape the value? + $variables[] = 'E=_ROUTING_'.$key.':'.strtr($value, array( + ':' => '\\:', + '=' => '\\=', + '\\' => '\\\\', + )); } $variables = implode(',', $variables); - $conditions = array(); - if ($req = $route->getRequirement('_method')) { - $conditions[] = sprintf('RewriteCond %%{REQUEST_METHOD} ^(%s) [NC]', $req); + $rule = array("# $name"); + + // method mismatch + if ($req = strtolower($route->getRequirement('_method'))) { + $allow = array(); + foreach (explode('|', $req) as $method) { + $methodVars[] = $var = '_ROUTING__allow_'.$method; + $allow[] = 'E='.$var.':1'; + } + + $rule[] = "RewriteCond %{REQUEST_URI} $regex"; + $rule[] = "RewriteCond %{REQUEST_METHOD} !^($req)$ [NC]"; + $rule[] = sprintf('RewriteRule .* - [S=%d,%s]', $hasTrailingSlash ? 2 : 1, implode(',', $allow)); } - $conditions = count($conditions) ? implode(" [OR]\n", $conditions)."\n" : ''; + // redirect with trailing slash appended + if ($hasTrailingSlash) { + $rule[] = 'RewriteCond %{REQUEST_URI} '.substr($regex, 0, -2).'$'; + $rule[] = 'RewriteRule .* $0/ [QSA,L,R=301]'; + } - $regexes[] = sprintf("%sRewriteCond %%{PATH_INFO} %s\nRewriteRule .* %s [QSA,L,%s]", $conditions, $regex, $options['script_name'], $variables); + // the main rule + $rule[] = "RewriteCond %{REQUEST_URI} $regex"; + $rule[] = "RewriteRule .* {$options['script_name']} [QSA,L,$variables]"; - // add redirect for missing trailing slash - if ('/$' === substr($regex, -2)) { - $regexes[count($regexes)-1] .= sprintf("\nRewriteCond %%{PATH_INFO} %s\nRewriteRule .* /$0/ [QSA,L,R=301]", substr($regex, 0, -2).'$'); + $rules[] = implode("\n", $rule); + } + + if (0 < count($methodVars)) { + $rule = array('# 405 Method Not Allowed'); + foreach ($methodVars as $i => $methodVar) { + $rule[] = sprintf('RewriteCond %%{%s} !-z%s', $methodVar, isset($methodVars[$i + 1]) ? ' [OR]' : ''); } + $rule[] = sprintf('RewriteRule .* %s [QSA,L]', $options['script_name']); + + $rules[] = implode("\n", $rule); } - return implode("\n\n", $regexes); + return implode("\n\n", $rules)."\n"; } } diff --git a/src/Symfony/Component/Routing/Matcher/Dumper/PhpMatcherDumper.php b/src/Symfony/Component/Routing/Matcher/Dumper/PhpMatcherDumper.php index 5fa3a9bb5c9f2..f1670d67f0271 100644 --- a/src/Symfony/Component/Routing/Matcher/Dumper/PhpMatcherDumper.php +++ b/src/Symfony/Component/Routing/Matcher/Dumper/PhpMatcherDumper.php @@ -56,10 +56,6 @@ protected function addMatcher() $conditions = array(); - if ($req = $route->getRequirement('_method')) { - $conditions[] = sprintf("isset(\$this->context['method']) && preg_match('#^(%s)$#xi', \$this->context['method'])", $req); - } - $hasTrailingSlash = false; if (!count($compiledRoute->getVariables()) && false !== preg_match('#^(.)\^(?P.*?)\$\1#', $compiledRoute->getRegex(), $m)) { if (substr($m['url'], -1) === '/') { @@ -90,9 +86,20 @@ protected function addMatcher() $conditions = implode(' && ', $conditions); $code[] = <<getRequirement('_method')) { + $req = implode('\', \'', array_map('strtolower', explode('|', $req))); + $code[] = <<context['method']) && !in_array(strtolower(\$this->context['method']), array('$req'))) { + \$allow = array_merge(\$allow, array('$req')); + goto not_$name; + } +EOF; + } + if ($hasTrailingSlash) { $code[] = sprintf(<<mergeDefaults($matches, %s), array('_route' => '%s')); } - EOF , str_replace("\n", '', var_export($compiledRoute->getDefaults(), true)), $name); + + if ($req) { + $code[] = << + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Routing\Matcher\Exception; + +/** + * A matching exception. + * + * @author Kris Wallsmith + */ +interface Exception +{ +} diff --git a/src/Symfony/Component/Routing/Matcher/Exception/MethodNotAllowedException.php b/src/Symfony/Component/Routing/Matcher/Exception/MethodNotAllowedException.php new file mode 100644 index 0000000000000..88f3a71abafa1 --- /dev/null +++ b/src/Symfony/Component/Routing/Matcher/Exception/MethodNotAllowedException.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Routing\Matcher\Exception; + +/** + * The resource was found but the request method is not allowed. + * + * This exception should trigger an HTTP 405 response in your application code. + * + * @author Kris Wallsmith + */ +class MethodNotAllowedException extends \RuntimeException implements Exception +{ + protected $allowedMethods; + + public function __construct(array $allowedMethods, $message = null, $code = 0, \Exception $previous = null) + { + $this->allowedMethods = $allowedMethods; + + parent::__construct($message, $code, $previous); + } + + public function getAllowedMethods() + { + return $this->allowedMethods; + } +} diff --git a/src/Symfony/Component/Routing/Matcher/Exception/NotFoundException.php b/src/Symfony/Component/Routing/Matcher/Exception/NotFoundException.php new file mode 100644 index 0000000000000..093611c3560f4 --- /dev/null +++ b/src/Symfony/Component/Routing/Matcher/Exception/NotFoundException.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\Matcher\Exception; + +/** + * The resource was not found. + * + * This exception should trigger an HTTP 404 response in your application code. + * + * @author Kris Wallsmith + */ +class NotFoundException extends \RuntimeException implements Exception +{ +} diff --git a/src/Symfony/Component/Routing/Matcher/UrlMatcher.php b/src/Symfony/Component/Routing/Matcher/UrlMatcher.php index 50f13dd21855e..86b2c46826b4e 100644 --- a/src/Symfony/Component/Routing/Matcher/UrlMatcher.php +++ b/src/Symfony/Component/Routing/Matcher/UrlMatcher.php @@ -11,6 +11,8 @@ namespace Symfony\Component\Routing\Matcher; +use Symfony\Component\Routing\Matcher\Exception\MethodNotAllowedException; +use Symfony\Component\Routing\Matcher\Exception\NotFoundException; use Symfony\Component\Routing\Route; use Symfony\Component\Routing\RouteCollection; @@ -52,23 +54,20 @@ public function setContext(array $context = array()) /** * Tries to match a URL with a set of routes. * - * Returns false if no route matches the URL. - * * @param string $pathinfo The path info to be parsed * - * @return array|false An array of parameters or false if no route matches + * @return array An array of parameters + * + * @throws NotFoundException If the resource could not be found + * @throws MethodNotAllowedException If the resource was found but the request method is not allowed */ public function match($pathinfo) { + $allow = array(); + foreach ($this->routes->all() as $name => $route) { $compiledRoute = $route->compile(); - // check HTTP method requirement - - if (isset($this->context['method']) && (($req = $route->getRequirement('_method')) && !preg_match(sprintf('#^(%s)$#xi', $req), $this->context['method']))) { - continue; - } - // check the static prefix of the URL first. Only use the more expensive preg_match when it matches if ('' !== $compiledRoute->getStaticPrefix() && 0 !== strpos($pathinfo, $compiledRoute->getStaticPrefix())) { continue; @@ -78,10 +77,18 @@ public function match($pathinfo) continue; } + // check HTTP method requirement + if (isset($this->context['method']) && ($req = explode('|', $route->getRequirement('_method'))) && !in_array(strtolower($this->context['method']), array_map('strtolower', $req))) { + $allow = array_merge($allow, $req); + continue; + } + return array_merge($this->mergeDefaults($matches, $route->getDefaults()), array('_route' => $name)); } - return false; + throw 0 < count($allow) + ? new MethodNotAllowedException(array_unique(array_map('strtolower', $allow))) + : new NotFoundException(); } protected function mergeDefaults($params, $defaults) diff --git a/src/Symfony/Component/Routing/Matcher/UrlMatcherInterface.php b/src/Symfony/Component/Routing/Matcher/UrlMatcherInterface.php index 394637451dacb..a566a64f3bb3c 100644 --- a/src/Symfony/Component/Routing/Matcher/UrlMatcherInterface.php +++ b/src/Symfony/Component/Routing/Matcher/UrlMatcherInterface.php @@ -12,7 +12,7 @@ namespace Symfony\Component\Routing\Matcher; /** - * UrlMatcherInterface is the interface that all URL matcher classes must implements. + * UrlMatcherInterface is the interface that all URL matcher classes must implement. * * @author Fabien Potencier */ @@ -21,11 +21,12 @@ interface UrlMatcherInterface /** * Tries to match a URL with a set of routes. * - * Returns false if no route matches the URL. - * * @param string $pathinfo The path info to be parsed * - * @return array|false An array of parameters or false if no route matches + * @return array An array of parameters + * + * @throws NotFoundException If the resource could not be found + * @throws MethodNotAllowedException If the resource was found but the request method is not allowed */ function match($pathinfo); } diff --git a/tests/Symfony/Tests/Component/Routing/Fixtures/dumper/url_matcher1.apache b/tests/Symfony/Tests/Component/Routing/Fixtures/dumper/url_matcher1.apache index de7ed61a94935..e4e468c75d6d8 100644 --- a/tests/Symfony/Tests/Component/Routing/Fixtures/dumper/url_matcher1.apache +++ b/tests/Symfony/Tests/Component/Routing/Fixtures/dumper/url_matcher1.apache @@ -1,16 +1,49 @@ -RewriteCond %{PATH_INFO} ^/foo/(baz|symfony)$ +# skip "real" requests +RewriteCond %{REQUEST_FILENAME} -f +RewriteRule .* - [QSA,L] + +# foo +RewriteCond %{REQUEST_URI} ^/foo/(baz|symfony)$ RewriteRule .* app.php [QSA,L,E=_ROUTING__route:foo,E=_ROUTING_bar:%1,E=_ROUTING_def:test] -RewriteCond %{REQUEST_METHOD} ^(GET|head) [NC] -RewriteCond %{PATH_INFO} ^/bar/([^/\.]+?)$ +# bar +RewriteCond %{REQUEST_URI} ^/bar/([^/\.]+?)$ +RewriteCond %{REQUEST_METHOD} !^(get|head)$ [NC] +RewriteRule .* - [S=1,E=_ROUTING__allow_get:1,E=_ROUTING__allow_head:1] +RewriteCond %{REQUEST_URI} ^/bar/([^/\.]+?)$ RewriteRule .* app.php [QSA,L,E=_ROUTING__route:bar,E=_ROUTING_foo:%1] -RewriteCond %{PATH_INFO} ^/test/baz/$ +# baz +RewriteCond %{REQUEST_URI} ^/test/baz$ RewriteRule .* app.php [QSA,L,E=_ROUTING__route:baz] -RewriteCond %{PATH_INFO} ^/test/baz$ -RewriteRule .* /$0/ [QSA,L,R=301] -RewriteCond %{PATH_INFO} ^/test/([^/\.]+?)/$ -RewriteRule .* app.php [QSA,L,E=_ROUTING__route:baz2,E=_ROUTING_foo:%1] -RewriteCond %{PATH_INFO} ^/test/([^/\.]+?)$ -RewriteRule .* /$0/ [QSA,L,R=301] \ No newline at end of file +# baz2 +RewriteCond %{REQUEST_URI} ^/test/baz\.html$ +RewriteRule .* app.php [QSA,L,E=_ROUTING__route:baz2] + +# baz3 +RewriteCond %{REQUEST_URI} ^/test/baz3$ +RewriteRule .* $0/ [QSA,L,R=301] +RewriteCond %{REQUEST_URI} ^/test/baz3/$ +RewriteRule .* app.php [QSA,L,E=_ROUTING__route:baz3] + +# baz4 +RewriteCond %{REQUEST_URI} ^/test/([^/\.]+?)$ +RewriteRule .* $0/ [QSA,L,R=301] +RewriteCond %{REQUEST_URI} ^/test/([^/\.]+?)/$ +RewriteRule .* app.php [QSA,L,E=_ROUTING__route:baz4,E=_ROUTING_foo:%1] + +# baz5 +RewriteCond %{REQUEST_URI} ^/test/([^/\.]+?)/$ +RewriteCond %{REQUEST_METHOD} !^(post)$ [NC] +RewriteRule .* - [S=2,E=_ROUTING__allow_post:1] +RewriteCond %{REQUEST_URI} ^/test/([^/\.]+?)$ +RewriteRule .* $0/ [QSA,L,R=301] +RewriteCond %{REQUEST_URI} ^/test/([^/\.]+?)/$ +RewriteRule .* app.php [QSA,L,E=_ROUTING__route:baz5,E=_ROUTING_foo:%1] + +# 405 Method Not Allowed +RewriteCond %{_ROUTING__allow_get} !-z [OR] +RewriteCond %{_ROUTING__allow_head} !-z [OR] +RewriteCond %{_ROUTING__allow_post} !-z +RewriteRule .* app.php [QSA,L] diff --git a/tests/Symfony/Tests/Component/Routing/Fixtures/dumper/url_matcher1.php b/tests/Symfony/Tests/Component/Routing/Fixtures/dumper/url_matcher1.php index a6d599e1e73aa..58e4e2006c44a 100644 --- a/tests/Symfony/Tests/Component/Routing/Fixtures/dumper/url_matcher1.php +++ b/tests/Symfony/Tests/Component/Routing/Fixtures/dumper/url_matcher1.php @@ -1,5 +1,8 @@ baz|symfony)$#x', $pathinfo, $matches)) { return array_merge($this->mergeDefaults($matches, array ( 'def' => 'test',)), array('_route' => 'foo')); } - if (isset($this->context['method']) && preg_match('#^(GET|head)$#xi', $this->context['method']) && 0 === strpos($pathinfo, '/bar') && preg_match('#^/bar/(?P[^/\.]+?)$#x', $pathinfo, $matches)) { + // bar + if (0 === strpos($pathinfo, '/bar') && preg_match('#^/bar/(?P[^/\.]+?)$#x', $pathinfo, $matches)) { + if (isset($this->context['method']) && !in_array(strtolower($this->context['method']), array('get', 'head'))) { + $allow = array_merge($allow, array('get', 'head')); + goto not_bar; + } return array_merge($this->mergeDefaults($matches, array ()), array('_route' => 'bar')); } + not_bar: + // baz if ($pathinfo === '/test/baz') { return array_merge($this->mergeDefaults(array(), array ()), array('_route' => 'baz')); } + // baz2 if ($pathinfo === '/test/baz.html') { return array_merge($this->mergeDefaults(array(), array ()), array('_route' => 'baz2')); } + // baz3 if (rtrim($pathinfo, '/') === '/test/baz3') { if (substr($pathinfo, -1) !== '/') { return array('_controller' => 'Symfony\Bundle\FrameworkBundle\Controller\RedirectController::urlRedirectAction', 'url' => $this->context['base_url'].$pathinfo.'/', 'permanent' => true, '_route' => 'baz3'); @@ -42,6 +57,7 @@ public function match($pathinfo) return array_merge($this->mergeDefaults(array(), array ()), array('_route' => 'baz3')); } + // baz4 if (0 === strpos($pathinfo, '/test') && preg_match('#^/test/(?P[^/\.]+?)/?$#x', $pathinfo, $matches)) { if (substr($pathinfo, -1) !== '/') { return array('_controller' => 'Symfony\Bundle\FrameworkBundle\Controller\RedirectController::urlRedirectAction', 'url' => $this->context['base_url'].$pathinfo.'/', 'permanent' => true, '_route' => 'baz4'); @@ -49,6 +65,19 @@ public function match($pathinfo) return array_merge($this->mergeDefaults($matches, array ()), array('_route' => 'baz4')); } - return false; + // baz5 + if (0 === strpos($pathinfo, '/test') && preg_match('#^/test/(?P[^/\.]+?)/?$#x', $pathinfo, $matches)) { + if (isset($this->context['method']) && !in_array(strtolower($this->context['method']), array('post'))) { + $allow = array_merge($allow, array('post')); + goto not_baz5; + } + if (substr($pathinfo, -1) !== '/') { + return array('_controller' => 'Symfony\Bundle\FrameworkBundle\Controller\RedirectController::urlRedirectAction', 'url' => $this->context['base_url'].$pathinfo.'/', 'permanent' => true, '_route' => 'baz5'); + } + return array_merge($this->mergeDefaults($matches, array ()), array('_route' => 'baz5')); + } + not_baz5: + + throw 0 < count($allow) ? new MethodNotAllowedException(array_unique($allow)) : new NotFoundException(); } } diff --git a/tests/Symfony/Tests/Component/Routing/Matcher/Dumper/ApacheMatcherDumperTest.php b/tests/Symfony/Tests/Component/Routing/Matcher/Dumper/ApacheMatcherDumperTest.php index c82855202f736..bf88ef2ea3ce8 100644 --- a/tests/Symfony/Tests/Component/Routing/Matcher/Dumper/ApacheMatcherDumperTest.php +++ b/tests/Symfony/Tests/Component/Routing/Matcher/Dumper/ApacheMatcherDumperTest.php @@ -28,22 +28,40 @@ public function testDump() { $collection = new RouteCollection(); + // defaults and requirements $collection->add('foo', new Route( '/foo/{bar}', array('def' => 'test'), array('bar' => 'baz|symfony') )); + // method requirement $collection->add('bar', new Route( '/bar/{foo}', array(), array('_method' => 'GET|head') )); + // simple $collection->add('baz', new Route( - '/test/baz/' + '/test/baz' )); + // simple with extension $collection->add('baz2', new Route( + '/test/baz.html' + )); + // trailing slash + $collection->add('baz3', new Route( + '/test/baz3/' + )); + // trailing slash with variable + $collection->add('baz4', new Route( '/test/{foo}/' )); + // trailing slash and method + $collection->add('baz5', new Route( + '/test/{foo}/', + array(), + array('_method' => 'post') + )); $dumper = new ApacheMatcherDumper($collection); diff --git a/tests/Symfony/Tests/Component/Routing/Matcher/Dumper/PhpMatcherDumperTest.php b/tests/Symfony/Tests/Component/Routing/Matcher/Dumper/PhpMatcherDumperTest.php index 998f1e4c445f2..1b974cb0de389 100644 --- a/tests/Symfony/Tests/Component/Routing/Matcher/Dumper/PhpMatcherDumperTest.php +++ b/tests/Symfony/Tests/Component/Routing/Matcher/Dumper/PhpMatcherDumperTest.php @@ -11,9 +11,9 @@ namespace Symfony\Tests\Component\Routing; +use Symfony\Component\Routing\Matcher\Dumper\PhpMatcherDumper; use Symfony\Component\Routing\Route; use Symfony\Component\Routing\RouteCollection; -use Symfony\Component\Routing\Matcher\Dumper\PhpMatcherDumper; class PhpMatcherDumperTest extends \PHPUnit_Framework_TestCase { @@ -28,28 +28,40 @@ public function testDump() { $collection = new RouteCollection(); + // defaults and requirements $collection->add('foo', new Route( '/foo/{bar}', array('def' => 'test'), array('bar' => 'baz|symfony') )); + // method requirement $collection->add('bar', new Route( '/bar/{foo}', array(), array('_method' => 'GET|head') )); + // simple $collection->add('baz', new Route( '/test/baz' )); + // simple with extension $collection->add('baz2', new Route( '/test/baz.html' )); + // trailing slash $collection->add('baz3', new Route( '/test/baz3/' )); + // trailing slash with variable $collection->add('baz4', new Route( '/test/{foo}/' )); + // trailing slash and method + $collection->add('baz5', new Route( + '/test/{foo}/', + array(), + array('_method' => 'post') + )); $dumper = new PhpMatcherDumper($collection); $this->assertStringEqualsFile(self::$fixturesPath.'/dumper/url_matcher1.php', $dumper->dump(), '->dump() dumps basic routes to the correct PHP file.'); diff --git a/tests/Symfony/Tests/Component/Routing/Matcher/UrlMatcherTest.php b/tests/Symfony/Tests/Component/Routing/Matcher/UrlMatcherTest.php index d2abbc577fcc2..1084df70925f7 100644 --- a/tests/Symfony/Tests/Component/Routing/Matcher/UrlMatcherTest.php +++ b/tests/Symfony/Tests/Component/Routing/Matcher/UrlMatcherTest.php @@ -11,43 +11,80 @@ namespace Symfony\Tests\Component\Routing\Matcher; +use Symfony\Component\Routing\Matcher\Exception\MethodNotAllowedException; +use Symfony\Component\Routing\Matcher\Exception\NotFoundException; use Symfony\Component\Routing\Matcher\UrlMatcher; -use Symfony\Component\Routing\RouteCollection; use Symfony\Component\Routing\Route; +use Symfony\Component\Routing\RouteCollection; class UrlMatcherTest extends \PHPUnit_Framework_TestCase { + public function testMethodNotAllowed() + { + $coll = new RouteCollection(); + $coll->add('foo', new Route('/foo', array(), array('_method' => 'post'))); + + $matcher = new UrlMatcher($coll, array('method' => 'get')); + + try { + $matcher->match('/foo'); + $this->fail(); + } catch (MethodNotAllowedException $e) { + $this->assertEquals(array('post'), $e->getAllowedMethods()); + } + } + + public function testMethodNotAllowedAggregatesAllowedMethods() + { + $coll = new RouteCollection(); + $coll->add('foo1', new Route('/foo', array(), array('_method' => 'post'))); + $coll->add('foo2', new Route('/foo', array(), array('_method' => 'put|delete'))); + + $matcher = new UrlMatcher($coll, array('method' => 'get')); + + try { + $matcher->match('/foo'); + $this->fail(); + } catch (MethodNotAllowedException $e) { + $this->assertEquals(array('post', 'put', 'delete'), $e->getAllowedMethods()); + } + } + public function testMatch() { - // test the patterns are matched are parameters are returned - $collection = new RouteCollection(); - $collection->add('foo', new Route('/foo/{bar}')); - $matcher = new UrlMatcher($collection, array(), array()); - $this->assertEquals(false, $matcher->match('/no-match')); - $this->assertEquals(array('_route' => 'foo', 'bar' => 'baz'), $matcher->match('/foo/baz')); - - // test that defaults are merged - $collection = new RouteCollection(); - $collection->add('foo', new Route('/foo/{bar}', array('def' => 'test'))); - $matcher = new UrlMatcher($collection, array(), array()); - $this->assertEquals(array('_route' => 'foo', 'bar' => 'baz', 'def' => 'test'), $matcher->match('/foo/baz')); - - // test that route "method" is ignore if no method is given in the context - $collection = new RouteCollection(); - $collection->add('foo', new Route('/foo', array(), array('_method' => 'GET|head'))); - - // route matches with no context - $matcher = new UrlMatcher($collection, array(), array()); - $this->assertNotEquals(false, $matcher->match('/foo')); - - // route does not match with POST method context - $matcher = new UrlMatcher($collection, array('method' => 'POST'), array()); - $this->assertEquals(false, $matcher->match('/foo')); - - // route does match with GET or HEAD method context - $matcher = new UrlMatcher($collection, array('method' => 'GET'), array()); - $this->assertNotEquals(false, $matcher->match('/foo')); - $matcher = new UrlMatcher($collection, array('method' => 'HEAD'), array()); - $this->assertNotEquals(false, $matcher->match('/foo')); + // test the patterns are matched are parameters are returned + $collection = new RouteCollection(); + $collection->add('foo', new Route('/foo/{bar}')); + $matcher = new UrlMatcher($collection, array(), array()); + try { + $matcher->match('/no-match'); + $this->fail(); + } catch (NotFoundException $e) {} + $this->assertEquals(array('_route' => 'foo', 'bar' => 'baz'), $matcher->match('/foo/baz')); + + // test that defaults are merged + $collection = new RouteCollection(); + $collection->add('foo', new Route('/foo/{bar}', array('def' => 'test'))); + $matcher = new UrlMatcher($collection, array(), array()); + $this->assertEquals(array('_route' => 'foo', 'bar' => 'baz', 'def' => 'test'), $matcher->match('/foo/baz')); + + // test that route "method" is ignored if no method is given in the context + $collection = new RouteCollection(); + $collection->add('foo', new Route('/foo', array(), array('_method' => 'GET|head'))); + $matcher = new UrlMatcher($collection, array(), array()); + $this->assertInternalType('array', $matcher->match('/foo')); + + // route does not match with POST method context + $matcher = new UrlMatcher($collection, array('method' => 'POST'), array()); + try { + $matcher->match('/foo'); + $this->fail(); + } catch (MethodNotAllowedException $e) {} + + // route does match with GET or HEAD method context + $matcher = new UrlMatcher($collection, array('method' => 'GET'), array()); + $this->assertInternalType('array', $matcher->match('/foo')); + $matcher = new UrlMatcher($collection, array('method' => 'HEAD'), array()); + $this->assertInternalType('array', $matcher->match('/foo')); } } diff --git a/tests/Symfony/Tests/Component/Routing/RouteTest.php b/tests/Symfony/Tests/Component/Routing/RouteTest.php index 6306ad1c6c126..c06c3254ce4b1 100644 --- a/tests/Symfony/Tests/Component/Routing/RouteTest.php +++ b/tests/Symfony/Tests/Component/Routing/RouteTest.php @@ -79,10 +79,6 @@ public function testRequirements() $route->setRequirements(array('foo' => '^\d+$')); $this->assertEquals('\d+', $route->getRequirement('foo'), '->getRequirement() removes ^ and $ from the pattern'); $this->assertEquals($route, $route->setRequirements(array()), '->setRequirements() implements a fluent interface'); - - // test that an array requirement throws an exception - $this->setExpectedException('InvalidArgumentException'); - $route->setRequirements(array('foo' => array('bar', 'baz'))); } public function testRequirement()