瀏覽代碼

[Routing] refactored URL matching to support 405 Method Not Allowed responses

Kris Wallsmith 14 年之前
父節點
當前提交
b2f5ac8beb

+ 35 - 36
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 @@ use Symfony\Component\Routing\RouteCollection;
  */
 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);
+        }
     }
 }

+ 51 - 22
src/Symfony/Component/Routing/Matcher/Dumper/ApacheMatcherDumper.php

@@ -14,67 +14,96 @@ namespace Symfony\Component\Routing\Matcher\Dumper;
 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 <fabien@symfony.com>
+ * @author Kris Wallsmith <kris@symfony.com>
  */
 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";
     }
 }

+ 25 - 6
src/Symfony/Component/Routing/Matcher/Dumper/PhpMatcherDumper.php

@@ -56,10 +56,6 @@ class PhpMatcherDumper extends MatcherDumper
 
             $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<url>.*?)\$\1#', $compiledRoute->getRegex(), $m)) {
                 if (substr($m['url'], -1) === '/') {
@@ -90,9 +86,20 @@ class PhpMatcherDumper extends MatcherDumper
             $conditions = implode(' && ', $conditions);
 
             $code[] = <<<EOF
+        // $name
         if ($conditions) {
 EOF;
 
+            if ($req = $route->getRequirement('_method')) {
+                $req = implode('\', \'', array_map('strtolower', explode('|', $req)));
+                $code[] = <<<EOF
+            if (isset(\$this->context['method']) && !in_array(strtolower(\$this->context['method']), array('$req'))) {
+                \$allow = array_merge(\$allow, array('$req'));
+                goto not_$name;
+            }
+EOF;
+            }
+
             if ($hasTrailingSlash) {
                 $code[] = sprintf(<<<EOF
             if (substr(\$pathinfo, -1) !== '/') {
@@ -105,9 +112,16 @@ EOF
             $code[] = sprintf(<<<EOF
             return array_merge(\$this->mergeDefaults($matches, %s), array('_route' => '%s'));
         }
-
 EOF
             , str_replace("\n", '', var_export($compiledRoute->getDefaults(), true)), $name);
+
+            if ($req) {
+                $code[] = <<<EOF
+        not_$name:
+EOF;
+            }
+
+            $code[] = '';
         }
 
         $code = implode("\n", $code);
@@ -116,8 +130,10 @@ EOF
 
     public function match(\$pathinfo)
     {
+        \$allow = array();
+
 $code
-        return false;
+        throw 0 < count(\$allow) ? new MethodNotAllowedException(array_unique(\$allow)) : new NotFoundException();
     }
 
 EOF;
@@ -128,6 +144,9 @@ EOF;
         return <<<EOF
 <?php
 
+use Symfony\Component\Routing\Matcher\Exception\MethodNotAllowedException;
+use Symfony\Component\Routing\Matcher\Exception\NotFoundException;
+
 /**
  * $class
  *

+ 21 - 0
src/Symfony/Component/Routing/Matcher/Exception/Exception.php

@@ -0,0 +1,21 @@
+<?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\Matcher\Exception;
+
+/**
+ * A matching exception.
+ *
+ * @author Kris Wallsmith <kris@symfony.com>
+ */
+interface Exception
+{
+}

+ 36 - 0
src/Symfony/Component/Routing/Matcher/Exception/MethodNotAllowedException.php

@@ -0,0 +1,36 @@
+<?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\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 <kris@symfony.com>
+ */
+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;
+    }
+}

+ 23 - 0
src/Symfony/Component/Routing/Matcher/Exception/NotFoundException.php

@@ -0,0 +1,23 @@
+<?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\Matcher\Exception;
+
+/**
+ * The resource was not found.
+ *
+ * This exception should trigger an HTTP 404 response in your application code.
+ *
+ * @author Kris Wallsmith <kris@symfony.com>
+ */
+class NotFoundException extends \RuntimeException implements Exception
+{
+}

+ 17 - 10
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 @@ class UrlMatcher implements 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
      */
     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 @@ class UrlMatcher implements UrlMatcherInterface
                 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)

+ 5 - 4
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 <fabien@symfony.com>
  */
@@ -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);
 }

+ 30 - 9
tests/Symfony/Tests/Component/Routing/Fixtures/dumper/url_matcher1.apache

@@ -1,16 +1,37 @@
-RewriteCond %{PATH_INFO} ^/foo/(baz|symfony)$
+# foo
+RewriteCond %{PATH_INFO}, ^/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_METHOD} !^(get|head)$ [NC]
+RewriteRule .* - [S=1,E=_ROUTING__allow_get:1,E=_ROUTING__allow_head:1]
+RewriteCond %{PATH_INFO}, ^/bar/([^/\.]+?)$
 RewriteRule .* app.php [QSA,L,E=_ROUTING__route:bar,E=_ROUTING_foo:%1]
 
-RewriteCond %{PATH_INFO} ^/test/baz/$
+# baz
+RewriteCond %{PATH_INFO}, ^/test/baz$
 RewriteRule .* app.php [QSA,L,E=_ROUTING__route:baz]
-RewriteCond %{PATH_INFO} ^/test/baz$
+
+# baz2
+RewriteCond %{PATH_INFO}, ^/test/baz\.html$
+RewriteRule .* app.php [QSA,L,E=_ROUTING__route:baz2]
+
+# baz3
+RewriteCond %{PATH_INFO}, ^/test/baz3/$
+RewriteRule .* app.php [QSA,L,E=_ROUTING__route:baz3]
+RewriteCond %{PATH_INFO}, ^/test/baz3$
 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]
+# baz4
+RewriteCond %{PATH_INFO}, ^/test/([^/\.]+?)/$
+RewriteRule .* app.php [QSA,L,E=_ROUTING__route:baz4,E=_ROUTING_foo:%1]
+RewriteCond %{PATH_INFO}, ^/test/([^/\.]+?)$
+RewriteRule .* /$0/ [QSA,L,R=301]
+
+# baz5
+RewriteCond %{REQUEST_METHOD} !^(post)$ [NC]
+RewriteRule .* - [S=2,E=_ROUTING__allow_post:1]
+RewriteCond %{PATH_INFO}, ^/test/([^/\.]+?)/$
+RewriteRule .* app.php [QSA,L,E=_ROUTING__route:baz5,E=_ROUTING_foo:%1]
+RewriteCond %{PATH_INFO}, ^/test/([^/\.]+?)$
+RewriteRule .* /$0/ [QSA,L,R=301]

+ 31 - 2
tests/Symfony/Tests/Component/Routing/Fixtures/dumper/url_matcher1.php

@@ -1,5 +1,8 @@
 <?php
 
+use Symfony\Component\Routing\Matcher\Exception\MethodNotAllowedException;
+use Symfony\Component\Routing\Matcher\Exception\NotFoundException;
+
 /**
  * ProjectUrlMatcher
  *
@@ -19,22 +22,34 @@ class ProjectUrlMatcher extends Symfony\Component\Routing\Matcher\UrlMatcher
 
     public function match($pathinfo)
     {
+        $allow = array();
+
+        // foo
         if (0 === strpos($pathinfo, '/foo') && preg_match('#^/foo/(?P<bar>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<foo>[^/\.]+?)$#x', $pathinfo, $matches)) {
+        // bar
+        if (0 === strpos($pathinfo, '/bar') && preg_match('#^/bar/(?P<foo>[^/\.]+?)$#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 @@ class ProjectUrlMatcher extends Symfony\Component\Routing\Matcher\UrlMatcher
             return array_merge($this->mergeDefaults(array(), array ()), array('_route' => 'baz3'));
         }
 
+        // baz4
         if (0 === strpos($pathinfo, '/test') && preg_match('#^/test/(?P<foo>[^/\.]+?)/?$#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 @@ class ProjectUrlMatcher extends Symfony\Component\Routing\Matcher\UrlMatcher
             return array_merge($this->mergeDefaults($matches, array ()), array('_route' => 'baz4'));
         }
 
-        return false;
+        // baz5
+        if (0 === strpos($pathinfo, '/test') && preg_match('#^/test/(?P<foo>[^/\.]+?)/?$#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();
     }
 }

+ 19 - 1
tests/Symfony/Tests/Component/Routing/Matcher/Dumper/ApacheMatcherDumperTest.php

@@ -28,22 +28,40 @@ class ApacheMatcherDumperTest extends \PHPUnit_Framework_TestCase
     {
         $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);
 

+ 13 - 1
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 @@ class PhpMatcherDumperTest extends \PHPUnit_Framework_TestCase
     {
         $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.');

+ 68 - 31
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'));
     }
 }

+ 0 - 4
tests/Symfony/Tests/Component/Routing/RouteTest.php

@@ -79,10 +79,6 @@ class RouteTest extends \PHPUnit_Framework_TestCase
         $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()