Skip to content

Commit

Permalink
[Routing] refactored URL matching to support 405 Method Not Allowed r…
Browse files Browse the repository at this point in the history
…esponses
  • Loading branch information
kriswallsmith committed Mar 21, 2011
1 parent 85778ca commit b2f5ac8
Show file tree
Hide file tree
Showing 14 changed files with 374 additions and 126 deletions.
71 changes: 35 additions & 36 deletions src/Symfony/Component/Routing/Matcher/ApacheUrlMatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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";
}
}
31 changes: 25 additions & 6 deletions src/Symfony/Component/Routing/Matcher/Dumper/PhpMatcherDumper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<url>.*?)\$\1#', $compiledRoute->getRegex(), $m)) {
if (substr($m['url'], -1) === '/') {
Expand Down Expand Up @@ -90,9 +86,20 @@ protected function addMatcher()
$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) !== '/') {
Expand All @@ -105,9 +112,16 @@ protected function addMatcher()
$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);
Expand All @@ -116,8 +130,10 @@ protected function addMatcher()
public function match(\$pathinfo)
{
\$allow = array();
$code
return false;
throw 0 < count(\$allow) ? new MethodNotAllowedException(array_unique(\$allow)) : new NotFoundException();
}
EOF;
Expand All @@ -128,6 +144,9 @@ protected function startClass($class, $baseClass)
return <<<EOF
<?php
use Symfony\Component\Routing\Matcher\Exception\MethodNotAllowedException;
use Symfony\Component\Routing\Matcher\Exception\NotFoundException;
/**
* $class
*
Expand Down
21 changes: 21 additions & 0 deletions src/Symfony/Component/Routing/Matcher/Exception/Exception.php
Original file line number Diff line number Diff line change
@@ -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
{
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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
{
}
Loading

0 comments on commit b2f5ac8

Please sign in to comment.