浏览代码

Merge pull request #3825 from markstory/3.0-router-scopes

3.0 router scopes
José Lorenzo Rodríguez 11 年之前
父节点
当前提交
422d5f0e37

+ 3 - 0
src/Routing/Route/RedirectRoute.php

@@ -49,6 +49,9 @@ class RedirectRoute extends Route {
  */
 	public function __construct($template, $defaults = [], array $options = []) {
 		parent::__construct($template, $defaults, $options);
+		if (is_array($defaults) && isset($defaults['redirect'])) {
+			$defaults = $defaults['redirect'];
+		}
 		$this->redirect = (array)$defaults;
 	}
 

+ 0 - 275
src/Routing/RouteCollection.php

@@ -1,275 +0,0 @@
-<?php
-/**
- * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
- * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
- *
- * Licensed under The MIT License
- * Redistributions of files must retain the above copyright notice.
- *
- * @copyright     Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
- * @link          http://cakephp.org CakePHP(tm) Project
- * @since         3.0.0
- * @license       http://www.opensource.org/licenses/mit-license.php MIT License
- */
-namespace Cake\Routing;
-
-use Cake\Network\Request;
-use Cake\Routing\Route\Route;
-
-/**
- * RouteCollection is used to operate on a set of routes.
- * It stores routes both in a linear list in order of connection, as well
- * as storing them in a hash-table indexed by a routes' name.
- *
- */
-class RouteCollection implements \Countable {
-
-/**
- * A hash table of routes indexed by route names.
- * Used for reverse routing.
- *
- * @var array
- */
-	protected $_routeTable = [];
-
-/**
- * A list of routes connected, in the order they were connected.
- * Used for parsing incoming urls.
- *
- * @var array
- */
-	protected $_routes = [];
-
-/**
- * The top most request's context. Updated whenever
- * requests are pushed/popped off the stack in Router.
- *
- * @var array
- */
-	protected $_requestContext = [
-		'_base' => '',
-		'_port' => 80,
-		'_scheme' => 'http',
-		'_host' => 'localhost',
-	];
-
-/**
- * Add a route to the collection.
- *
- * Appends the route to the list of routes, and the route hashtable.
- *
- * @param \Cake\Routing\Route\Route $route The route to add
- * @return void
- */
-	public function add(Route $route) {
-		$name = $route->getName();
-		if (!isset($this->_routeTable[$name])) {
-			$this->_routeTable[$name] = [];
-		}
-		$this->_routeTable[$name][] = $route;
-		$this->_routes[] = $route;
-	}
-
-/**
- * Reverse route or match a $url array with the defined routes.
- * Returns either the string URL generate by the route, or false on failure.
- *
- * @param array $url The url to match.
- * @return void
- */
-	public function match($url) {
-		$names = $this->_getNames($url);
-		unset($url['_name']);
-		foreach ($names as $name) {
-			if (isset($this->_routeTable[$name])) {
-				$output = $this->_matchRoutes($this->_routeTable[$name], $url);
-				if ($output) {
-					return $output;
-				}
-			}
-		}
-		return $this->_matchRoutes($this->_routes, $url);
-	}
-
-/**
- * Matches a set of routes with a given $url and $params
- *
- * @param array $routes An array of routes to match against.
- * @param array $url The url to match.
- * @return mixed Either false on failure, or a string on success.
- */
-	protected function _matchRoutes($routes, $url) {
-		for ($i = 0, $len = count($routes); $i < $len; $i++) {
-			$match = $routes[$i]->match($url, $this->_requestContext);
-			if ($match) {
-				return strlen($match) > 1 ? trim($match, '/') : $match;
-			}
-		}
-		return false;
-	}
-
-/**
- * Get the set of names from the $url.  Accepts both older style array urls,
- * and newer style urls containing '_name'
- *
- * @param array $url The url to match.
- * @return string The name of the url
- */
-	protected function _getNames($url) {
-		$name = false;
-		if (isset($url['_name'])) {
-			$name = $url['_name'];
-		}
-		$plugin = false;
-		if (isset($url['plugin'])) {
-			$plugin = $url['plugin'];
-		}
-		$fallbacks = [
-			'%2$s:%3$s',
-			'%2$s:_action',
-			'_controller:%3$s',
-			'_controller:_action'
-		];
-		if ($plugin) {
-			$fallbacks = [
-				'%1$s.%2$s:%3$s',
-				'%1$s.%2$s:_action',
-				'%1$s._controller:%3$s',
-				'%1$s._controller:_action',
-				'_plugin._controller:%3$s',
-				'_plugin._controller:_action',
-				'_controller:_action'
-			];
-		}
-		foreach ($fallbacks as $i => $template) {
-			$fallbacks[$i] = strtolower(sprintf($template, $plugin, $url['controller'], $url['action']));
-		}
-		if ($name) {
-			array_unshift($fallbacks, $name);
-		}
-		return $fallbacks;
-	}
-
-/**
- * Takes the URL string and iterates the routes until one is able to parse the route.
- *
- * @param string $url Url to parse.
- * @return array An array of request parameters parsed from the url.
- */
-	public function parse($url) {
-		$queryParameters = null;
-		if (strpos($url, '?') !== false) {
-			list($url, $queryParameters) = explode('?', $url, 2);
-			parse_str($queryParameters, $queryParameters);
-		}
-		$out = array();
-		for ($i = 0, $len = count($this); $i < $len; $i++) {
-			$r = $this->_routes[$i]->parse($url);
-			if ($r !== false && $queryParameters) {
-				$r['?'] = $queryParameters;
-				return $r;
-			}
-			if ($r !== false) {
-				return $r;
-			}
-		}
-		return $out;
-	}
-
-/**
- * Promote a route (by default, the last one added) to the beginning of the list.
- * Also promotes the route to the head of its named slice in the named route
- * table.
- *
- * @param int $which A zero-based array index representing
- *    the route to move. For example,
- *    if 3 routes have been added, the last route would be 2.
- * @return bool Returns false if no route exists at the position
- *    specified by $which.
- */
-	public function promote($which) {
-		if ($which === null) {
-			$which = count($this->_routes) - 1;
-		}
-		if (!isset($this->_routes[$which])) {
-			return false;
-		}
-		$route =& $this->_routes[$which];
-		unset($this->_routes[$which]);
-		array_unshift($this->_routes, $route);
-
-		$name = $route->getName();
-		$routes = $this->_routeTable[$name];
-		$index = array_search($route, $routes, true);
-		unset($this->_routeTable[$name][$index]);
-		array_unshift($this->_routeTable[$name], $route);
-		return true;
-	}
-
-/**
- * Get route(s) out of the collection.
- *
- * If a string argument is provided, the first matching
- * route for the provided name will be returned.
- *
- * If an integer argument is provided, the route
- * with that index will be returned.
- *
- * @param mixed $index The index or name of the route you want.
- * @return mixed Either the route object or null.
- */
-	public function get($index) {
-		if (is_string($index)) {
-			$routes = isset($this->_routeTable[$index]) ? $this->_routeTable[$index] : [null];
-			return $routes[0];
-		}
-		return isset($this->_routes[$index]) ? $this->_routes[$index] : null;
-	}
-
-/**
- * Get the list of all connected routes.
- *
- * @return array.
- */
-	public function all() {
-		return $this->_routes;
-	}
-
-/**
- * Part of the countable interface.
- *
- * @return int The number of connected routes.
- */
-	public function count() {
-		return count($this->_routes);
-	}
-
-/**
- * Populate the request context used to generate URL's
- * Generally set to the last/most recent request.
- *
- * @param \Cake\Network\Request $request Request instance.
- * @return void
- */
-	public function setContext(Request $request) {
-		$this->_requestContext = [
-			'_base' => $request->base,
-			'_port' => $request->port(),
-			'_scheme' => $request->scheme(),
-			'_host' => $request->host()
-		];
-	}
-
-/**
- * Sets which extensions routes will use.
- *
- * @param array $extensions The extensions for routes to use.
- * @return void
- */
-	public function parseExtensions(array $extensions) {
-		foreach ($this->_routes as $route) {
-			$route->parseExtensions($extensions);
-		}
-	}
-
-}

+ 178 - 94
src/Routing/Router.php

@@ -18,7 +18,6 @@ use Cake\Core\App;
 use Cake\Core\Configure;
 use Cake\Error;
 use Cake\Network\Request;
-use Cake\Routing\RouteCollection;
 use Cake\Routing\ScopedRouteCollection;
 use Cake\Routing\Route\Route;
 use Cake\Utility\Inflector;
@@ -39,13 +38,6 @@ use Cake\Utility\Inflector;
 class Router {
 
 /**
- * RouteCollection object containing all the connected routes.
- *
- * @var \Cake\Routing\RouteCollection
- */
-	protected static $_routes;
-
-/**
  * Have routes been loaded
  *
  * @var bool
@@ -125,6 +117,29 @@ class Router {
 	protected static $_pathScopes = [];
 
 /**
+ * A hash of ScopedRouteCollection objects indexed by plugin + prefix
+ *
+ * @var array
+ */
+	protected static $_paramScopes = [];
+
+/**
+ * A hash of request context data.
+ *
+ * @var array
+ */
+	protected static $_requestContext = [];
+
+/**
+ * A hash of named routes. Indexed
+ * as an optimization so reverse routing does not
+ * have to traverse all the route collections.
+ *
+ * @var array
+ */
+	protected static $_named = [];
+
+/**
  * Named expressions
  *
  * @var array
@@ -185,28 +200,6 @@ class Router {
 	protected static $_urlFilters = [];
 
 /**
- * Default route class to use
- *
- * @var string
- */
-	protected static $_routeClass = 'Cake\Routing\Route\Route';
-
-/**
- * Set the default route class to use or return the current one
- *
- * @param string $routeClass to set as default
- * @return mixed void|string
- * @throws \Cake\Error\Exception
- */
-	public static function defaultRouteClass($routeClass = null) {
-		if ($routeClass === null) {
-			return static::$_routeClass;
-		}
-
-		static::$_routeClass = static::_validateRouteClass($routeClass);
-	}
-
-/**
  * Validates that the passed route class exists and is a subclass of Cake Route
  *
  * @param string $routeClass Route class name
@@ -336,24 +329,9 @@ class Router {
  */
 	public static function connect($route, $defaults = [], $options = []) {
 		static::$initialized = true;
-
-		$defaults += ['plugin' => null];
-		if (empty($options['action'])) {
-			$defaults += array('action' => 'index');
-		}
-		if (empty($options['_ext'])) {
-			$options['_ext'] = static::$_validExtensions;
-		}
-		$routeClass = static::$_routeClass;
-		if (isset($options['routeClass'])) {
-			$routeClass = App::className($options['routeClass'], 'Routing/Route');
-			$routeClass = static::_validateRouteClass($routeClass);
-			unset($options['routeClass']);
-		}
-		if ($routeClass === 'Cake\Routing\Route\RedirectRoute' && isset($defaults['redirect'])) {
-			$defaults = $defaults['redirect'];
-		}
-		static::$_routes->add(new $routeClass($route, $defaults, $options));
+		Router::scope('/', function($routes) use ($route, $defaults, $options) {
+			$routes->connect($route, $defaults, $options);
+		});
 	}
 
 /**
@@ -391,7 +369,7 @@ class Router {
 	public static function redirect($route, $url, $options = []) {
 		$options['routeClass'] = 'Cake\Routing\Route\RedirectRoute';
 		if (is_string($url)) {
-			$url = array('redirect' => $url);
+			$url = ['redirect' => $url];
 		}
 		return static::connect($route, $url, $options);
 	}
@@ -503,26 +481,23 @@ class Router {
  *
  * @param string $url URL to be parsed
  * @return array Parsed elements from URL
+ * @throws \Cake\Error\Exception When a route cannot be handled
  */
 	public static function parse($url) {
 		if (!static::$initialized) {
 			static::_loadRoutes();
 		}
-
-		if (strlen($url) && strpos($url, '/') !== 0) {
+		if (strpos($url, '/') !== 0) {
 			$url = '/' . $url;
 		}
-		return static::$_routes->parse($url);
-	}
 
-/**
- * Set the route collection object Router should use.
- *
- * @param \Cake\Routing\RouteCollection $routes Routes collection.
- * @return void
- */
-	public static function setRouteCollection(RouteCollection $routes) {
-		static::$_routes = $routes;
+		foreach (static::$_pathScopes as $path => $collection) {
+			if (strpos($url, $path) === 0) {
+				return $collection->parse($url);
+			}
+		}
+		// TODO improve this with a custom exception.
+		throw new Error\Exception('No routes match the given URL.');
 	}
 
 /**
@@ -565,7 +540,22 @@ class Router {
  */
 	public static function pushRequest(Request $request) {
 		static::$_requests[] = $request;
-		static::$_routes->setContext($request);
+		static::_setContext($request);
+	}
+
+/**
+ * Store the request context for a given request.
+ *
+ * @param \Cake\Network\Request $request The request instance.
+ * @return void
+ */
+	protected static function _setContext($request) {
+		static::$_requestContext = [
+			'_base' => $request->base,
+			'_port' => $request->port(),
+			'_scheme' => $request->scheme(),
+			'_host' => $request->host()
+		];
 	}
 
 /**
@@ -579,7 +569,7 @@ class Router {
 		$removed = array_pop(static::$_requests);
 		$last = end(static::$_requests);
 		if ($last) {
-			static::$_routes->setContext($last);
+			static::_setContext($last);
 			reset(static::$_requests);
 		}
 		return $removed;
@@ -607,8 +597,6 @@ class Router {
 	public static function reload() {
 		if (empty(static::$_initialState)) {
 			static::$_initialState = get_class_vars(get_called_class());
-			static::_setPrefixes();
-			static::$_routes = new RouteCollection();
 			return;
 		}
 		foreach (static::$_initialState as $key => $val) {
@@ -616,19 +604,6 @@ class Router {
 				static::${$key} = $val;
 			}
 		}
-		static::_setPrefixes();
-		static::$_routes = new RouteCollection();
-	}
-
-/**
- * Promote a route (by default, the last one added) to the beginning of the list
- *
- * @param int $which A zero-based array index representing the route to move. For example,
- *    if 3 routes have been added, the last route would be 2.
- * @return bool Returns false if no route exists at the position specified by $which.
- */
-	public static function promote($which = null) {
-		return static::$_routes->promote($which);
 	}
 
 /**
@@ -809,28 +784,18 @@ class Router {
 				'controller' => $params['controller'],
 				'action' => 'index',
 				'_ext' => $params['_ext']
-
 			);
 			$url = static::_applyUrlFilters($url);
-			$output = static::$_routes->match($url);
+			$output = static::_match($url);
 		} elseif (
 			$urlType === 'string' &&
 			!$hasLeadingSlash &&
 			!$plainString
 		) {
 			// named route.
-			$route = static::$_routes->get($url);
-			if (!$route) {
-				throw new Error\Exception(sprintf(
-					'No route matching the name "%s" was found.',
-					$url
-				));
-			}
-			$url = $options +
-				$route->defaults +
-				array('_name' => $url);
+			$url = $options + ['_name' => $url];
 			$url = static::_applyUrlFilters($url);
-			$output = static::$_routes->match($url);
+			$output = static::_match($url);
 		} else {
 			// String urls.
 			if ($plainString) {
@@ -849,6 +814,53 @@ class Router {
 	}
 
 /**
+ * Find a Route that matches the given URL data.
+ *
+ * @param string|array The URL to match.
+ * @return string The generated URL
+ * @throws \Cake\Error\Exception When a matching URL cannot be found.
+ */
+	protected static function _match($url) {
+		// Named routes support hack.
+		if (isset($url['_name'])) {
+			$route = false;
+			if (isset(static::$_named[$url['_name']])) {
+				$route = static::$_named[$url['_name']];
+			}
+			if ($route) {
+				unset($url['_name']);
+				return $route->match($url + $route->defaults, static::$_requestContext);
+			}
+		}
+
+		// Check the scope that matches key params.
+		$plugin = isset($url['plugin']) ? $url['plugin'] : '';
+		$prefix = isset($url['prefix']) ? $url['prefix'] : '';
+
+		$collection = null;
+		$attempts = [[$plugin, $prefix], ['', '']];
+		foreach ($attempts as $attempt) {
+			if (isset(static::$_paramScopes[$attempt[0]][$attempt[1]])) {
+				$collection = static::$_paramScopes[$attempt[0]][$attempt[1]];
+				break;
+			}
+		}
+
+		if ($collection) {
+			$match = $collection->match($url, static::$_requestContext);
+			if ($match !== false) {
+				return $match;
+			}
+		}
+
+		// TODO improve with custom exception
+		throw new Error\Exception(sprintf(
+			'Unable to find a matching route for %s',
+			var_export($url, true)
+		));
+	}
+
+/**
  * Sets the full base URL that will be used as a prefix for generating
  * fully qualified URLs for this application. If not parameters are passed,
  * the currently configured value is returned.
@@ -972,7 +984,6 @@ class Router {
 		if ($merge) {
 			$extensions = array_merge(static::$_validExtensions, $extensions);
 		}
-		static::$_routes->parseExtensions($extensions);
 		return static::$_validExtensions = $extensions;
 	}
 
@@ -1072,14 +1083,19 @@ class Router {
  * `$routes->extensions()` in your closure.
  *
  * @param string $path The path prefix for the scope. This path will be prepended
- *    to all routes connected in the scoped collection.
+ *   to all routes connected in the scoped collection.
  * @param array $params An array of routing defaults to add to each connected route.
  *   If you have no parameters, this argument can be a callable.
  * @param callable $callback The callback to invoke with the scoped collection.
  * @throws \InvalidArgumentException When an invalid callable is provided.
- * @return void
+ * @return null|\Cake\Routing\ScopedRouteCollection The scoped collection that
+ *   was created/used.
  */
 	public static function scope($path, $params = [], $callback = null) {
+		if ($params === [] && $callback === null && isset(static::$_pathScopes[$path])) {
+			return static::$_pathScopes[$path];
+		}
+
 		if ($callback === null) {
 			$callback = $params;
 			$params = [];
@@ -1092,11 +1108,79 @@ class Router {
 		$collection = new ScopedRouteCollection($path, $params, static::$_validExtensions);
 		$callback($collection);
 
+		// Index named routes for fast lookup.
+		static::$_named += $collection->named();
+
+		// Index scopes by path (for parsing)
 		if (empty(static::$_pathScopes[$path])) {
 			static::$_pathScopes[$path] = $collection;
+			krsort(static::$_pathScopes);
 		} else {
 			static::$_pathScopes[$path]->merge($collection);
 		}
+
+		// Index scopes by key params (for reverse routing).
+		$plugin = isset($params['plugin']) ? $params['plugin'] : '';
+		$prefix = isset($params['prefix']) ? $params['prefix'] : '';
+		if (!isset(static::$_paramScopes[$plugin][$prefix])) {
+			static::$_paramScopes[$plugin][$prefix] = $collection;
+		} else {
+			static::$_paramScopes[$plugin][$prefix]->merge($collection);
+		}
+	}
+
+/**
+ * Create prefixed routes.
+ *
+ * This method creates a scoped route collection that includes
+ * relevant prefix information.
+ *
+ * The path parameter is used to generate the routing parameter name.
+ * For example a path of `admin` would result in `'prefix' => 'admin'` being
+ * applied to all connected routes.
+ *
+ * You can re-open a prefix as many times as necessary, as well as nest prefixes.
+ * Nested prefixes will result in prefix values like `admin/api` which translates
+ * to the `Controller\Admin\Api\` namespace.
+ *
+ * @param string $name The prefix name to use.
+ * @param callable $callback The callback to invoke that builds the prefixed routes.
+ * @return void
+ */
+	public static function prefix($name, $callback) {
+		$name = Inflector::underscore($name);
+		$path = '/' . $name;
+		static::scope($path, ['prefix' => $name], $callback);
+	}
+
+/**
+* Add plugin routes.
+*
+* This method creates a scoped route collection that includes
+* relevant plugin information.
+*
+* The plugin name will be inflected to the underscore version to create
+* the routing path. If you want a custom path name, use the `path` option.
+*
+* Routes connected in the scoped collection will have the correct path segment
+* prepended, and have a matching plugin routing key set.
+*
+* @param string $path The path name to use for the prefix.
+* @param array|callable $options Either the options to use, or a callback.
+* @param callable $callback The callback to invoke that builds the plugin routes.
+*   Only required when $options is defined.
+* @return void
+*/
+	public static function plugin($name, $options = [], $callback = null) {
+		if ($callback === null) {
+			$callback = $options;
+			$options = [];
+		}
+		$params = ['plugin' => $name];
+		if (empty($options['path'])) {
+			$options['path'] = '/' . Inflector::underscore($name);
+		}
+		static::scope($options['path'], $params, $callback);
 	}
 
 /**

+ 47 - 33
src/Routing/ScopedRouteCollection.php

@@ -343,7 +343,6 @@ class ScopedRouteCollection {
  * @throws \Cake\Error\Exception
  */
 	public function connect($route, array $defaults = [], $options = []) {
-		$defaults += ['plugin' => null];
 		if (empty($options['action'])) {
 			$defaults += array('action' => 'index');
 		}
@@ -352,25 +351,7 @@ class ScopedRouteCollection {
 			$options['_ext'] = $this->_extensions;
 		}
 
-		// TODO don't hardcode
-		$routeClass = 'Cake\Routing\Route\Route';
-		if (isset($options['routeClass'])) {
-			$routeClass = App::className($options['routeClass'], 'Routing/Route');
-			$routeClass = $this->_validateRouteClass($routeClass);
-			unset($options['routeClass']);
-		}
-		if ($routeClass === 'Cake\Routing\Route\RedirectRoute' && isset($defaults['redirect'])) {
-			$defaults = $defaults['redirect'];
-		}
-
-		$route = str_replace('//', '/', $this->_path . $route);
-		if (is_array($defaults)) {
-			$defaults += $this->_params;
-		}
-
-		// Store the route and named index if possible.
-		$route = new $routeClass($route, $defaults, $options);
-
+		$route = $this->_makeRoute($route, $defaults, $options);
 		if (isset($options['_name'])) {
 			$this->_named[$options['_name']] = $route;
 		}
@@ -384,20 +365,52 @@ class ScopedRouteCollection {
 	}
 
 /**
- * Validates that the passed route class exists and is a subclass of Cake\Routing\Route\Route
+ * Create a route object, or return the provided object.
  *
- * @param string $routeClass Route class name
- * @return string
- * @throws \Cake\Error\Exception
+ * @param string|\Cake\Routing\Route\Route $route The route template or route object.
+ * @param array $defaults Default parameters.
+ * @param array $options Additional options parameters.
+ * @return \Cake\Routing\Route\Route
+ * @throws \Cake\Error\Exception when route class or route object is invalid.
  */
-	protected function _validateRouteClass($routeClass) {
-		if (
-			$routeClass !== 'Cake\Routing\Route\Route' &&
-			(!class_exists($routeClass) || !is_subclass_of($routeClass, 'Cake\Routing\Route\Route'))
-		) {
-			throw new Error\Exception('Route class not found, or route class is not a subclass of Cake\Routing\Route\Route');
+	protected function _makeRoute($route, $defaults, $options) {
+		if (is_string($route)) {
+			$routeClass = 'Cake\Routing\Route\Route';
+			if (isset($options['routeClass'])) {
+				$routeClass = App::className($options['routeClass'], 'Routing/Route');
+			}
+			if ($routeClass === false) {
+				throw new Error\Exception(sprintf('Cannot find route class %s', $options['routeClass']));
+			}
+			unset($options['routeClass']);
+
+			$route = str_replace('//', '/', $this->_path . $route);
+			if (!is_array($defaults)) {
+				debug(\Cake\Utility\Debugger::trace());
+			}
+			foreach ($this->_params as $param => $val) {
+				if (isset($defaults[$param]) && $defaults[$param] !== $val) {
+					$msg = 'You cannot define routes that conflict with the scope. ' .
+						'Scope had %s = %s, while route had %s = %s';
+					throw new Error\Exception(sprintf(
+						$msg,
+						$param,
+						$val,
+						$param,
+						$defaults[$param]
+					));
+				}
+			}
+			$defaults += $this->_params;
+			$defaults += ['plugin' => null];
+
+			$route = new $routeClass($route, $defaults, $options);
+		}
+
+		if ($route instanceof Route) {
+			return $route;
 		}
-		return $routeClass;
+		throw new Error\Exception('Route class not found, or route class is not a subclass of Cake\Routing\Route\Route');
 	}
 
 /**
@@ -442,7 +455,7 @@ class ScopedRouteCollection {
 /**
  * Add prefixed routes.
  *
- * This method creates a new scoped route collection that includes
+ * This method creates a scoped route collection that includes
  * relevant prefix information.
  *
  * The path parameter is used to generate the routing parameter name.
@@ -559,7 +572,7 @@ class ScopedRouteCollection {
 	protected function _getNames($url) {
 		$name = false;
 		if (isset($url['_name'])) {
-			$name = $url['_name'];
+			return [$url['_name']];
 		}
 		$plugin = false;
 		if (isset($url['plugin'])) {
@@ -577,6 +590,7 @@ class ScopedRouteCollection {
 				'%1$s.%2$s:_action',
 				'%1$s._controller:%3$s',
 				'%1$s._controller:_action',
+				'_plugin.%2$s:%3$s',
 				'_plugin._controller:%3$s',
 				'_plugin._controller:_action',
 				'_controller:_action'

+ 1 - 2
tests/TestCase/Routing/Filter/CacheFilterTest.php

@@ -12,7 +12,7 @@
  * @since         3.0.0
  * @license       http://www.opensource.org/licenses/mit-license.php MIT License
  */
-namespace Cake\Test\TestCase\Routing;
+namespace Cake\Test\TestCase\Routing\Filter;
 
 use Cake\Cache\Cache;
 use Cake\Core\Configure;
@@ -81,7 +81,6 @@ class CacheFilterTest extends TestCase {
 		Configure::write('Cache.check', true);
 		Configure::write('debug', true);
 
-		Router::reload();
 		Router::connect('/', array('controller' => 'TestCachedPages', 'action' => 'index'));
 		Router::connect('/test_cached_pages/:action/*', ['controller' => 'TestCachedPages']);
 

+ 1 - 1
tests/TestCase/Routing/Filter/RoutingFilterTest.php

@@ -12,7 +12,7 @@
  * @since         3.0.0
  * @license       http://www.opensource.org/licenses/mit-license.php MIT License
  */
-namespace Cake\Test\TestCase\Routing;
+namespace Cake\Test\TestCase\Routing\Filter;
 
 use Cake\Event\Event;
 use Cake\Network\Request;

+ 119 - 179
tests/TestCase/Routing/RouterTest.php

@@ -286,11 +286,12 @@ class RouterTest extends TestCase {
  * @return void
  */
 	public function testMapResourcesWithExtension() {
+		Router::parseExtensions(['json', 'xml'], false);
+
 		$resources = Router::mapResources('Posts', ['_ext' => 'json']);
 		$this->assertEquals(['posts'], $resources);
 
 		$_SERVER['REQUEST_METHOD'] = 'GET';
-		Router::parseExtensions(['json', 'xml'], false);
 
 		$expected = array(
 			'plugin' => null,
@@ -317,39 +318,19 @@ class RouterTest extends TestCase {
  */
 	public function testMapResourcesConnectOptions() {
 		Plugin::load('TestPlugin');
-		$collection = new RouteCollection();
-		Router::setRouteCollection($collection);
 		Router::mapResources('Posts', array(
 			'connectOptions' => array(
 				'routeClass' => 'TestPlugin.TestRoute',
 				'foo' => '^(bar)$',
 			),
 		));
-		$route = $collection->get(0);
+		$routes = Router::scope('/');
+		$route = $routes->routes()[0];
 		$this->assertInstanceOf('TestPlugin\Routing\Route\TestRoute', $route);
 		$this->assertEquals('^(bar)$', $route->options['foo']);
 	}
 
 /**
- * Test that RouterCollection::all() gets the list of all connected routes.
- *
- * @return void
- */
-	public function testRouteCollectionRoutes() {
-		$collection = new RouteCollection();
-		Router::setRouteCollection($collection);
-		Router::mapResources('Posts');
-
-		$routes = $collection->all();
-
-		$this->assertEquals(count($routes), 6);
-		$this->assertInstanceOf('Cake\Routing\Route\Route', $routes[0]);
-		$this->assertEquals($collection->get(0), $routes[0]);
-		$this->assertInstanceOf('Cake\Routing\Route\Route', $routes[5]);
-		$this->assertEquals($collection->get(5), $routes[5]);
-	}
-
-/**
  * Test mapResources with a plugin and prefix.
  *
  * @return void
@@ -647,7 +628,6 @@ class RouterTest extends TestCase {
 		$this->assertEquals($expected, $result);
 
 		Router::connect('/view/*', array('controller' => 'posts', 'action' => 'view'));
-		Router::promote();
 		$result = Router::url(array('controller' => 'posts', 'action' => 'view', '1'));
 		$expected = '/view/1';
 		$this->assertEquals($expected, $result);
@@ -1652,7 +1632,6 @@ class RouterTest extends TestCase {
  * @return void
  */
 	public function testSetExtensions() {
-		Router::extensions();
 		Router::parseExtensions('rss', false);
 		$this->assertContains('rss', Router::extensions());
 
@@ -1665,15 +1644,6 @@ class RouterTest extends TestCase {
 		$this->assertFalse(isset($result['_ext']));
 
 		Router::parseExtensions(array('xml'));
-		$result = Router::extensions();
-		$this->assertContains('rss', $result);
-		$this->assertContains('xml', $result);
-
-		$result = Router::parse('/posts.xml');
-		$this->assertEquals('xml', $result['_ext']);
-
-		$result = Router::parseExtensions(array('pdf'), false);
-		$this->assertEquals(array('pdf'), $result);
 	}
 
 /**
@@ -1979,21 +1949,25 @@ class RouterTest extends TestCase {
  * @return void
  */
 	public function testRouteParamDefaults() {
-		Configure::write('Routing.prefixes', array('admin'));
-		Router::reload();
 		Router::connect('/cache/*', array('prefix' => false, 'plugin' => true, 'controller' => 0, 'action' => 1));
 
-		$url = Router::url(array('controller' => 0, 'action' => 1, 'test'));
-		$expected = '/';
-		$this->assertEquals($expected, $url);
-
-		$url = Router::url(array('prefix' => 1, 'controller' => 0, 'action' => 1, 'test'));
-		$expected = '/';
-		$this->assertEquals($expected, $url);
-
 		$url = Router::url(array('prefix' => 0, 'plugin' => 1, 'controller' => 0, 'action' => 1, 'test'));
 		$expected = '/cache/test';
 		$this->assertEquals($expected, $url);
+
+		try {
+			Router::url(array('controller' => 0, 'action' => 1, 'test'));
+			$this->fail('No exception raised');
+		} catch (\Exception $e) {
+			$this->assertTrue(true, 'Exception was raised');
+		}
+
+		try {
+			Router::url(array('prefix' => 1, 'controller' => 0, 'action' => 1, 'test'));
+			$this->fail('No exception raised');
+		} catch (\Exception $e) {
+			$this->assertTrue(true, 'Exception was raised');
+		}
 	}
 
 /**
@@ -2096,12 +2070,12 @@ class RouterTest extends TestCase {
  * @return void
  */
 	public function testParsingWithPatternOnAction() {
-		Router::reload();
 		Router::connect(
 			'/blog/:action/*',
 			array('controller' => 'blog_posts'),
 			array('action' => 'other|actions')
 		);
+
 		$result = Router::parse('/blog/other');
 		$expected = array(
 			'plugin' => null,
@@ -2114,11 +2088,26 @@ class RouterTest extends TestCase {
 		$result = Router::parse('/blog/foobar');
 		$this->assertSame([], $result);
 
-		$result = Router::url(array('controller' => 'blog_posts', 'action' => 'foo'));
-		$this->assertEquals('/', $result);
+	}
+
+/**
+ * Test url() works with patterns on :action
+ *
+ * @expectedException Cake\Error\Exception
+ * @return void
+ */
+	public function testUrlPatterOnAction() {
+		Router::connect(
+			'/blog/:action/*',
+			array('controller' => 'blog_posts'),
+			array('action' => 'other|actions')
+		);
 
 		$result = Router::url(array('controller' => 'blog_posts', 'action' => 'actions'));
 		$this->assertEquals('/blog/actions', $result);
+
+		$result = Router::url(array('controller' => 'blog_posts', 'action' => 'foo'));
+		$this->assertEquals('/', $result);
 	}
 
 /**
@@ -2291,8 +2280,15 @@ class RouterTest extends TestCase {
 
 		$result = Router::parse('/badness/test/test_action');
 		$this->assertSame([], $result);
+	}
 
-		Router::reload();
+/**
+ * testRegexRouteMatching method
+ *
+ * @expectedException Cake\Error\Exception
+ * @return void
+ */
+	public function testRegexRouteMatchUrl() {
 		Router::connect('/:locale/:controller/:action/*', [], array('locale' => 'dan|eng'));
 
 		$request = new Request();
@@ -2309,13 +2305,13 @@ class RouterTest extends TestCase {
 			))
 		);
 
-		$result = Router::url(array('action' => 'test_another_action'));
-		$expected = '/';
-		$this->assertEquals($expected, $result);
-
 		$result = Router::url(array('action' => 'test_another_action', 'locale' => 'eng'));
 		$expected = '/eng/test/test_another_action';
 		$this->assertEquals($expected, $result);
+
+		$result = Router::url(array('action' => 'test_another_action'));
+		$expected = '/';
+		$this->assertEquals($expected, $result);
 	}
 
 /**
@@ -2370,19 +2366,21 @@ class RouterTest extends TestCase {
  * @return void
  */
 	public function testUsingCustomRouteClass() {
-		$routes = $this->getMock('Cake\Routing\RouteCollection');
-		$this->getMock('Cake\Routing\Route\Route', [], [], 'MockConnectedRoute', false);
-		Router::setRouteCollection($routes);
-
-		$routes->expects($this->once())
-			->method('add')
-			->with($this->isInstanceOf('\MockConnectedRoute'));
-
+		Plugin::load('TestPlugin');
 		Router::connect(
 			'/:slug',
-			array('controller' => 'posts', 'action' => 'view'),
-			array('routeClass' => '\MockConnectedRoute', 'slug' => '[a-z_-]+')
+			array('plugin' => 'TestPlugin', 'action' => 'index'),
+			array('routeClass' => 'PluginShortRoute', 'slug' => '[a-z_-]+')
 		);
+		$result = Router::parse('/the-best');
+		$expected = [
+			'plugin' => 'TestPlugin',
+			'controller' => 'TestPlugin',
+			'action' => 'index',
+			'slug' => 'the-best',
+			'pass' => [],
+		];
+		$this->assertEquals($result, $expected);
 	}
 
 /**
@@ -2598,10 +2596,15 @@ class RouterTest extends TestCase {
 	public function testUrlFullUrlReturnFromRoute() {
 		$url = 'http://example.com/posts/view/1';
 
-		$routes = $this->getMock('Cake\Routing\RouteCollection');
-		Router::setRouteCollection($routes);
-		$routes->expects($this->any())->method('match')
+		$route = $this->getMock(
+			'Cake\Routing\Route\Route',
+			['match'],
+			['/:controller/:action/*']
+		);
+		$route->expects($this->any())
+			->method('match')
 			->will($this->returnValue($url));
+		Router::connect($route);
 
 		$result = Router::url(array('controller' => 'posts', 'action' => 'view', 1));
 		$this->assertEquals($url, $result);
@@ -2674,6 +2677,18 @@ class RouterTest extends TestCase {
 	}
 
 /**
+ * Test that redirect() works.
+ *
+ * @return void
+ */
+	public function testRedirect() {
+		Router::redirect('/mobile', '/', ['status' => 301]);
+		$scope = Router::scope('/');
+		$route = $scope->routes()[0];
+		$this->assertInstanceOf('Cake\Routing\Route\RedirectRoute', $route);
+	}
+
+/**
  * Tests resourceMap as getter and setter.
  *
  * @return void
@@ -2705,102 +2720,6 @@ class RouterTest extends TestCase {
 	}
 
 /**
- * test setting redirect routes
- *
- * @return void
- */
-	public function testRouteRedirection() {
-		$routes = new RouteCollection();
-		Router::setRouteCollection($routes);
-
-		Router::redirect('/blog', array('controller' => 'posts'), array('status' => 302));
-		Router::connect('/:controller', array('action' => 'index'));
-
-		$this->assertEquals(2, count($routes));
-
-		$routes->get(0)->response = $this->getMock(
-			'Cake\Network\Response',
-			array('_sendHeader', 'stop')
-		);
-		$this->assertEquals(302, $routes->get(0)->options['status']);
-
-		Router::parse('/blog');
-		$header = $routes->get(0)->response->header();
-		$this->assertEquals(Router::url('/posts', true), $header['Location']);
-		$this->assertEquals(302, $routes->get(0)->response->statusCode());
-
-		$routes->get(0)->response = $this->getMock(
-			'Cake\Network\Response',
-			array('_sendHeader')
-		);
-		Router::parse('/not-a-match');
-		$this->assertSame([], $routes->get(0)->response->header());
-	}
-
-/**
- * Test setting the default route class
- *
- * @return void
- */
-	public function testDefaultRouteClass() {
-		$routes = $this->getMock('Cake\Routing\RouteCollection');
-		$this->getMock('Cake\Routing\Route\Route', [], array('/test'), 'TestDefaultRouteClass');
-
-		$routes->expects($this->once())
-			->method('add')
-			->with($this->isInstanceOf('\TestDefaultRouteClass'));
-
-		Router::setRouteCollection($routes);
-		Router::defaultRouteClass('\TestDefaultRouteClass');
-
-		Router::connect('/', array('controller' => 'pages', 'action' => 'display', 'home'));
-	}
-
-/**
- * Test getting the default route class
- *
- * @return void
- */
-	public function testDefaultRouteClassGetter() {
-		$routeClass = '\TestDefaultRouteClass';
-		Router::defaultRouteClass($routeClass);
-
-		$this->assertEquals($routeClass, Router::defaultRouteClass());
-		$this->assertEquals($routeClass, Router::defaultRouteClass(null));
-	}
-
-/**
- * Test that route classes must extend Cake\Routing\Route\Route
- *
- * @expectedException \Cake\Error\Exception
- * @return void
- */
-	public function testDefaultRouteException() {
-		Router::defaultRouteClass('');
-		Router::connect('/:controller', []);
-	}
-
-/**
- * Test that route classes must extend Cake\Routing\Route\Route
- *
- * @expectedException \Cake\Error\Exception
- * @return void
- */
-	public function testSettingInvalidDefaultRouteException() {
-		Router::defaultRouteClass('Object');
-	}
-
-/**
- * Test that class must exist
- *
- * @expectedException \Cake\Error\Exception
- * @return void
- */
-	public function testSettingNonExistentDefaultRouteException() {
-		Router::defaultRouteClass('NonExistentClass');
-	}
-
-/**
  * Test that the compatibility method for incoming urls works.
  *
  * @return void
@@ -2839,24 +2758,6 @@ class RouterTest extends TestCase {
 	}
 
 /**
- * Test promote()
- *
- * @return void
- */
-	public function testPromote() {
-		Router::connect('/:controller/:action/*');
-		Router::connect('/:lang/:controller/:action/*');
-
-		$result = Router::url(['controller' => 'posts', 'action' => 'index', 'lang' => 'en']);
-		$this->assertEquals('/posts/index?lang=en', $result, 'First route should match');
-
-		Router::promote();
-
-		$result = Router::url(['controller' => 'posts', 'action' => 'index', 'lang' => 'en']);
-		$this->assertEquals('/en/posts/index', $result, 'promote() should move 2nd route ahead.');
-	}
-
-/**
  * Test the scope() method
  *
  * @return void
@@ -2885,4 +2786,43 @@ class RouterTest extends TestCase {
 		Router::scope('/path', 'derpy');
 	}
 
+/**
+ * Test that prefix() creates a scope.
+ *
+ * @return void
+ */
+	public function testPrefix() {
+		Router::prefix('admin', function($routes) {
+			$this->assertInstanceOf('Cake\Routing\ScopedRouteCollection', $routes);
+			$this->assertEquals('/admin', $routes->path());
+			$this->assertEquals(['prefix' => 'admin'], $routes->params());
+		});
+	}
+
+/**
+ * Test that plugin() creates a scope.
+ *
+ * @return void
+ */
+	public function testPlugin() {
+		Router::plugin('DebugKit', function($routes) {
+			$this->assertInstanceOf('Cake\Routing\ScopedRouteCollection', $routes);
+			$this->assertEquals('/debug_kit', $routes->path());
+			$this->assertEquals(['plugin' => 'DebugKit'], $routes->params());
+		});
+	}
+
+/**
+ * Test that plugin() accepts options
+ *
+ * @return void
+ */
+	public function testPluginOptions() {
+		Router::plugin('DebugKit', ['path' => '/debugger'], function($routes) {
+			$this->assertInstanceOf('Cake\Routing\ScopedRouteCollection', $routes);
+			$this->assertEquals('/debugger', $routes->path());
+			$this->assertEquals(['plugin' => 'DebugKit'], $routes->params());
+		});
+	}
+
 }

+ 37 - 6
tests/TestCase/Routing/ScopedRouteCollectionTest.php

@@ -102,6 +102,21 @@ class ScopedRouteCollectionTest extends TestCase {
 	}
 
 /**
+ * Test connecting an instance routes.
+ *
+ * @return void
+ */
+	public function testConnectInstance() {
+		$routes = new ScopedRouteCollection('/l', ['prefix' => 'api']);
+
+		$route = new Route('/:controller');
+		$this->assertNull($routes->connect($route));
+
+		$result = $routes->routes()[0];
+		$this->assertSame($route, $result);
+	}
+
+/**
  * Test connecting basic routes.
  *
  * @return void
@@ -152,6 +167,18 @@ class ScopedRouteCollectionTest extends TestCase {
 	}
 
 /**
+ * Test conflicting parameters raises an exception.
+ *
+ * @expectedException \Cake\Error\Exception
+ * @expectedExceptionMessage You cannot define routes that conflict with the scope.
+ * @return void
+ */
+	public function testConnectConflictingParameters() {
+		$routes = new ScopedRouteCollection('/admin', ['prefix' => 'admin'], []);
+		$routes->connect('/', ['prefix' => 'manager', 'controller' => 'Dashboard', 'action' => 'view']);
+	}
+
+/**
  * Test connecting redirect routes.
  *
  * @return void
@@ -210,6 +237,13 @@ class ScopedRouteCollectionTest extends TestCase {
 		$res = $routes->plugin('Contacts', function($r) {
 			$this->assertEquals('/b/contacts', $r->path());
 			$this->assertEquals(['plugin' => 'Contacts', 'key' => 'value'], $r->params());
+
+			$r->connect('/:controller');
+			$route = $r->routes()[0];
+			$this->assertEquals(
+				['key' => 'value', 'plugin' => 'Contacts', 'action' => 'index'],
+				$route->defaults
+			);
 		});
 		$this->assertNull($res);
 	}
@@ -304,14 +338,11 @@ class ScopedRouteCollectionTest extends TestCase {
 		$routes = new ScopedRouteCollection('/contacts', ['plugin' => 'Contacts']);
 		$routes->connect('/', ['controller' => 'Contacts']);
 
-		$result = $routes->match(
-			['plugin' => 'Contacts', 'controller' => 'Contacts', 'action' => 'index'],
-			$context
-		);
-		$this->assertFalse($result);
-
 		$result = $routes->match(['controller' => 'Contacts', 'action' => 'index'], $context);
 		$this->assertFalse($result);
+
+		$result = $routes->match(['plugin' => 'Contacts', 'controller' => 'Contacts', 'action' => 'index'], $context);
+		$this->assertEquals('contacts', $result);
 	}
 
 /**