Browse Source

Merge pull request #9982 from cakephp/3next-request-host-match

RFC - Allow routes to restrict the hosts they should match on
José Lorenzo Rodríguez 9 years ago
parent
commit
10a5205b36

+ 1 - 1
src/Routing/Filter/RoutingFilter.php

@@ -55,7 +55,7 @@ class RoutingFilter extends DispatcherFilter
 
         try {
             if (empty($request->params['controller'])) {
-                $params = Router::parse($request->url, $request->method());
+                $params = Router::parseRequest($request);
                 $request->addParams($params);
             }
 

+ 1 - 4
src/Routing/Middleware/RoutingMiddleware.php

@@ -45,10 +45,7 @@ class RoutingMiddleware
                 }
                 $request = $request->withAttribute(
                     'params',
-                    Router::parse(
-                        $request->getUri()->getPath(),
-                        $request->getMethod()
-                    )
+                    Router::parseRequest($request)
                 );
             }
         } catch (RedirectException $e) {

+ 37 - 1
src/Routing/Route/Route.php

@@ -16,6 +16,7 @@ namespace Cake\Routing\Route;
 
 use Cake\Http\ServerRequest;
 use Cake\Routing\Router;
+use Psr\Http\Message\ServerRequestInterface;
 
 /**
  * A single Route used by the Router to connect requests to
@@ -92,6 +93,9 @@ class Route
      *
      * - `_ext` - Defines the extensions used for this route.
      * - `pass` - Copies the listed parameters into params['pass'].
+     * - `_host` - Define the host name pattern if you want this route to only match
+     *   specific host names. You can use `.*` and to create wildcard subdomains/hosts
+     *   e.g. `*.example.com` matches all subdomains on `example.com`.
      *
      * @param string $template Template string with parameter placeholders
      * @param array|string $defaults Defaults for the route.
@@ -282,11 +286,31 @@ class Route
      * Checks to see if the given URL can be parsed by this route.
      *
      * If the route can be parsed an array of parameters will be returned; if not
+     * false will be returned.
+     *
+     * @param \Psr\Http\Message\ServerRequestInterface $request The URL to attempt to parse.
+     * @return array|false An array of request parameters, or false on failure.
+     */
+    public function parseRequest(ServerRequestInterface $request)
+    {
+        $uri = $request->getUri();
+        if (isset($this->options['_host']) && !$this->hostMatches($uri->getHost())) {
+            return false;
+        }
+
+        return $this->parse($uri->getPath(), $request->getMethod());
+    }
+
+    /**
+     * Checks to see if the given URL can be parsed by this route.
+     *
+     * If the route can be parsed an array of parameters will be returned; if not
      * false will be returned. String URLs are parsed if they match a routes regular expression.
      *
      * @param string $url The URL to attempt to parse.
      * @param string $method The HTTP method of the request being parsed.
      * @return array|false An array of request parameters, or false on failure.
+     * @deprecated 3.4.0 Use/implement parseRequest() instead as it provides more flexibility/control.
      */
     public function parse($url, $method = '')
     {
@@ -353,13 +377,25 @@ class Route
                 }
             }
         }
-
         $route['_matchedRoute'] = $this->template;
 
         return $route;
     }
 
     /**
+     * Check to see if the host matches the route requirements
+     *
+     * @param string $host The request's host name
+     * @return bool Whether or not the host matches any conditions set in for this route.
+     */
+    public function hostMatches($host)
+    {
+        $pattern = '@^' . str_replace('\*', '.*', preg_quote($this->options['_host'], '@')) . '$@';
+
+        return preg_match($pattern, $host) !== 0;
+    }
+
+    /**
      * Removes the extension from $url if it contains a registered extension.
      * If no registered extension is found, no extension is returned and the URL is returned unmodified.
      *

+ 34 - 0
src/Routing/RouteCollection.php

@@ -17,6 +17,7 @@ namespace Cake\Routing;
 use Cake\Routing\Exception\DuplicateNamedRouteException;
 use Cake\Routing\Exception\MissingRouteException;
 use Cake\Routing\Route\Route;
+use Psr\Http\Message\ServerRequestInterface;
 
 /**
  * Contains a collection of routes.
@@ -148,6 +149,39 @@ class RouteCollection
     }
 
     /**
+     * Takes the ServerRequestInterface, iterates the routes until one is able to parse the route.
+     *
+     * @param \Psr\Http\Messages\ServerRequestInterface $request The request to parse route data from.
+     * @return array An array of request parameters parsed from the URL.
+     * @throws \Cake\Routing\Exception\MissingRouteException When a URL has no matching route.
+     */
+    public function parseRequest(ServerRequestInterface $request)
+    {
+        $uri = $request->getUri();
+        $urlPath = $uri->getPath();
+        foreach (array_keys($this->_paths) as $path) {
+            if (strpos($urlPath, $path) !== 0) {
+                continue;
+            }
+
+            /* @var \Cake\Routing\Route\Route $route */
+            foreach ($this->_paths[$path] as $route) {
+                $r = $route->parseRequest($request);
+                if ($r === false) {
+                    continue;
+                }
+                if ($uri->getQuery()) {
+                    parse_str($uri->getQuery(), $queryParameters);
+                    $r['?'] = $queryParameters;
+                }
+
+                return $r;
+            }
+        }
+        throw new MissingRouteException(['url' => $urlPath]);
+    }
+
+    /**
      * Get the set of names from the $url.  Accepts both older style array urls,
      * and newer style urls containing '_name'
      *

+ 17 - 0
src/Routing/Router.php

@@ -338,6 +338,7 @@ class Router
      * @param string $method The HTTP method being used.
      * @return array Parsed elements from URL.
      * @throws \Cake\Routing\Exception\MissingRouteException When a route cannot be handled
+     * @deprecated 3.4.0 Use Router::parseRequest() instead.
      */
     public static function parse($url, $method = '')
     {
@@ -352,6 +353,22 @@ class Router
     }
 
     /**
+     * Get the routing parameters for the request is possible.
+     *
+     * @param \Psr\Http\Message\ServerRequestInterface $request The request to parse request data from.
+     * @return array Parsed elements from URL.
+     * @throws \Cake\Routing\Exception\MissingRouteException When a route cannot be handled
+     */
+    public static function parseRequest(ServerRequestInterface $request)
+    {
+        if (!static::$initialized) {
+            static::_loadRoutes();
+        }
+
+        return static::$_collection->parseRequest($request);
+    }
+
+    /**
      * Takes parameter and path information back from the Dispatcher, sets these
      * parameters as the current request parameters that are merged with URL arrays
      * created later in the request.

+ 74 - 0
tests/TestCase/Routing/Route/RouteTest.php

@@ -15,6 +15,7 @@
 namespace Cake\Test\TestCase\Routing\Route;
 
 use Cake\Core\Configure;
+use Cake\Http\ServerRequest;
 use Cake\Routing\Router;
 use Cake\Routing\Route\Route;
 use Cake\TestSuite\TestCase;
@@ -857,6 +858,79 @@ class RouteTest extends TestCase
     }
 
     /**
+     * Ensure that parseRequest() calls parse() as that is required
+     * for backwards compat
+     *
+     * @return void
+     */
+    public function testParseRequestDelegates()
+    {
+        $route = $this->getMockBuilder('Cake\Routing\Route\Route')
+            ->setMethods(['parse'])
+            ->setConstructorArgs(['/forward', ['controller' => 'Articles', 'action' => 'index']])
+            ->getMock();
+
+        $route->expects($this->once())
+            ->method('parse')
+            ->with('/forward', 'GET')
+            ->will($this->returnValue('works!'));
+
+        $request = new ServerRequest([
+            'environment' => [
+                'REQUEST_METHOD' => 'GET',
+                'PATH_INFO' => '/forward'
+            ]
+        ]);
+        $result = $route->parseRequest($request);
+    }
+
+    /**
+     * Test that parseRequest() applies host conditions
+     *
+     * @return void
+     */
+    public function testParseRequestHostConditions()
+    {
+        $route = new Route(
+            '/fallback',
+            ['controller' => 'Articles', 'action' => 'index'],
+            ['_host' => '*.example.com']
+        );
+
+        $request = new ServerRequest([
+            'environment' => [
+                'HTTP_HOST' => 'a.example.com',
+                'PATH_INFO' => '/fallback'
+            ]
+        ]);
+        $result = $route->parseRequest($request);
+        $expected = [
+            'controller' => 'Articles',
+            'action' => 'index',
+            'pass' => [],
+            '_matchedRoute' => '/fallback'
+        ];
+        $this->assertEquals($expected, $result, 'Should match, domain is correct');
+
+        $request = new ServerRequest([
+            'environment' => [
+                'HTTP_HOST' => 'foo.bar.example.com',
+                'PATH_INFO' => '/fallback'
+            ]
+        ]);
+        $result = $route->parseRequest($request);
+        $this->assertEquals($expected, $result, 'Should match, domain is a matching subdomain');
+
+        $request = new ServerRequest([
+            'environment' => [
+                'HTTP_HOST' => 'example.test.com',
+                'PATH_INFO' => '/fallback'
+            ]
+        ]);
+        $this->assertFalse($route->parseRequest($request));
+    }
+
+    /**
      * test the parse method of Route.
      *
      * @return void

+ 174 - 0
tests/TestCase/Routing/RouteCollectionTest.php

@@ -14,6 +14,8 @@
  */
 namespace Cake\Test\TestCase\Routing;
 
+use Cake\Http\ServerRequest;
+use Cake\Routing\Exception\MissingRouteException;
 use Cake\Routing\RouteBuilder;
 use Cake\Routing\RouteCollection;
 use Cake\Routing\Route\Route;
@@ -159,6 +161,178 @@ class RouteCollectionTest extends TestCase
     }
 
     /**
+     * Test parseRequest() throws an error on unknown routes.
+     *
+     * @expectedException \Cake\Routing\Exception\MissingRouteException
+     * @expectedExceptionMessage A route matching "/" could not be found
+     */
+    public function testParseRequestMissingRoute()
+    {
+        $routes = new RouteBuilder($this->collection, '/b', ['key' => 'value']);
+        $routes->connect('/', ['controller' => 'Articles']);
+        $routes->connect('/:id', ['controller' => 'Articles', 'action' => 'view']);
+
+        $request = new ServerRequest(['url' => '/']);
+        $result = $this->collection->parseRequest($request);
+        $this->assertEquals([], $result, 'Should not match, missing /b');
+    }
+
+    /**
+     * Test parseRequest() checks host conditions
+     *
+     * @return void
+     */
+    public function testParseRequestCheckHostCondition()
+    {
+        $routes = new RouteBuilder($this->collection, '/');
+        $routes->connect(
+            '/fallback',
+            ['controller' => 'Articles', 'action' => 'index'],
+            ['_host' => '*.example.com']
+        );
+
+        $request = new ServerRequest([
+            'environment' => [
+                'HTTP_HOST' => 'a.example.com',
+                'PATH_INFO' => '/fallback'
+            ]
+        ]);
+        $result = $this->collection->parseRequest($request);
+        $expected = [
+            'controller' => 'Articles',
+            'action' => 'index',
+            'pass' => [],
+            'plugin' => null,
+            '_matchedRoute' => '/fallback'
+        ];
+        $this->assertEquals($expected, $result, 'Should match, domain is correct');
+
+        $request = new ServerRequest([
+            'environment' => [
+                'HTTP_HOST' => 'foo.bar.example.com',
+                'PATH_INFO' => '/fallback'
+            ]
+        ]);
+        $result = $this->collection->parseRequest($request);
+        $this->assertEquals($expected, $result, 'Should match, domain is a matching subdomain');
+
+        $request = new ServerRequest([
+            'environment' => [
+                'HTTP_HOST' => 'example.test.com',
+                'PATH_INFO' => '/fallback'
+            ]
+        ]);
+        try {
+            $this->collection->parseRequest($request);
+            $this->fail('No exception raised');
+        } catch (MissingRouteException $e) {
+            $this->assertContains('/fallback', $e->getMessage());
+        }
+    }
+
+    /**
+     * Get a list of hostnames
+     *
+     * @return array
+     */
+    public static function hostProvider()
+    {
+        return [
+            ['wrong.example'],
+            ['example.com'],
+            ['aexample.com'],
+        ];
+    }
+
+    /**
+     * Test parseRequest() checks host conditions
+     *
+     * @dataProvider hostProvider
+     * @expectedException \Cake\Routing\Exception\MissingRouteException
+     * @expectedExceptionMessage A route matching "/fallback" could not be found
+     */
+    public function testParseRequestCheckHostConditionFail($host)
+    {
+        $routes = new RouteBuilder($this->collection, '/');
+        $routes->connect(
+            '/fallback',
+            ['controller' => 'Articles', 'action' => 'index'],
+            ['_host' => '*.example.com']
+        );
+
+        $request = new ServerRequest([
+            'environment' => [
+                'HTTP_HOST' => $host,
+                'PATH_INFO' => '/fallback'
+            ]
+        ]);
+        $this->collection->parseRequest($request);
+    }
+
+    /**
+     * Test parsing routes.
+     *
+     * @return void
+     */
+    public function testParseRequest()
+    {
+        $routes = new RouteBuilder($this->collection, '/b', ['key' => 'value']);
+        $routes->connect('/', ['controller' => 'Articles']);
+        $routes->connect('/:id', ['controller' => 'Articles', 'action' => 'view']);
+        $routes->connect('/media/search/*', ['controller' => 'Media', 'action' => 'search']);
+
+        $request = new ServerRequest(['url' => '/b/']);
+        $result = $this->collection->parseRequest($request);
+        $expected = [
+            'controller' => 'Articles',
+            'action' => 'index',
+            'pass' => [],
+            'plugin' => null,
+            'key' => 'value',
+            '_matchedRoute' => '/b',
+        ];
+        $this->assertEquals($expected, $result);
+
+        $request = new ServerRequest(['url' => '/b/media/search']);
+        $result = $this->collection->parseRequest($request);
+        $expected = [
+            'key' => 'value',
+            'pass' => [],
+            'plugin' => null,
+            'controller' => 'Media',
+            'action' => 'search',
+            '_matchedRoute' => '/b/media/search/*',
+        ];
+        $this->assertEquals($expected, $result);
+
+        $request = new ServerRequest(['url' => '/b/media/search/thing']);
+        $result = $this->collection->parseRequest($request);
+        $expected = [
+            'key' => 'value',
+            'pass' => ['thing'],
+            'plugin' => null,
+            'controller' => 'Media',
+            'action' => 'search',
+            '_matchedRoute' => '/b/media/search/*',
+        ];
+        $this->assertEquals($expected, $result);
+
+        $request = new ServerRequest(['url' => '/b/the-thing?one=two']);
+        $result = $this->collection->parseRequest($request);
+        $expected = [
+            'controller' => 'Articles',
+            'action' => 'view',
+            'id' => 'the-thing',
+            'pass' => [],
+            'plugin' => null,
+            'key' => 'value',
+            '?' => ['one' => 'two'],
+            '_matchedRoute' => '/b/:id',
+        ];
+        $this->assertEquals($expected, $result);
+    }
+
+    /**
      * Test match() throws an error on unknown routes.
      *
      * @expectedException \Cake\Routing\Exception\MissingRouteException

+ 20 - 0
tests/TestCase/Routing/RouterTest.php

@@ -1596,6 +1596,26 @@ class RouterTest extends TestCase
     }
 
     /**
+     * test parseRequest
+     *
+     * @return void
+     */
+    public function testParseRequest()
+    {
+        Router::connect('/articles/:action/*', ['controller' => 'Articles']);
+        $request = new Request(['url' => '/articles/view/1']);
+        $result = Router::parseRequest($request);
+        $expected = [
+            'pass' => ['1'],
+            'plugin' => null,
+            'controller' => 'Articles',
+            'action' => 'view',
+            '_matchedRoute' => '/articles/:action/*',
+        ];
+        $this->assertEquals($expected, $result);
+    }
+
+    /**
      * testUuidRoutes method
      *
      * @return void