diff --git a/core/includes/common.inc b/core/includes/common.inc index c47494e..aa5a32c 100644 --- a/core/includes/common.inc +++ b/core/includes/common.inc @@ -4,6 +4,7 @@ use Drupal\Component\Utility\NestedArray; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Drupal\Core\Cache\CacheBackendInterface; +use Symfony\Component\DependencyInjection\Container; use Drupal\Core\Database\Database; use Drupal\Core\Template\Attribute; @@ -6832,6 +6833,7 @@ function drupal_flush_all_caches() { // Rebuild the menu router based on all rebuilt data. // Important: This rebuild must happen last, so the menu router is guaranteed // to be based on up to date information. + drupal_container()->get('router.builder')->rebuild(); menu_router_rebuild(); // Re-initialize the maintenance theme, if the current request attempted to diff --git a/core/lib/Drupal/Core/CoreBundle.php b/core/lib/Drupal/Core/CoreBundle.php index 4ad6726..f671c57 100644 --- a/core/lib/Drupal/Core/CoreBundle.php +++ b/core/lib/Drupal/Core/CoreBundle.php @@ -15,6 +15,8 @@ use Symfony\Component\DependencyInjection\Scope; use Symfony\Component\HttpKernel\Bundle\Bundle; use Symfony\Component\DependencyInjection\Compiler\PassConfig; +use Drupal\Core\Database\Database; + /** * Bundle class for mandatory core services. * @@ -53,12 +55,25 @@ class CoreBundle extends Bundle ->setFactoryMethod('getConnection') ->addArgument('slave'); + $container->register('router.dumper', '\Drupal\Core\Routing\MatcherDumper') + ->addArgument(new Reference('database')); + $container->register('router.builder', 'Drupal\Core\Routing\RouteBuilder') + ->addArgument(new Reference('router.dumper')); + // @todo Replace below lines with the commented out block below it when it's // performant to do so: http://drupal.org/node/1706064. $dispatcher = $container->get('dispatcher'); - $matcher = new \Drupal\Core\LegacyUrlMatcher(); + $matcher = new \Drupal\Core\Routing\ChainMatcher(); + $matcher->add(new \Drupal\Core\LegacyUrlMatcher()); + + $nested = new \Drupal\Core\Routing\NestedMatcher(); + $nested->setInitialMatcher(new \Drupal\Core\Routing\PathMatcher(Database::getConnection())); + $nested->addPartialMatcher(new \Drupal\Core\Routing\HttpMethodMatcher()); + $nested->setFinalMatcher(new \Drupal\Core\Routing\FirstEntryFinalMatcher()); + $matcher->add($nested, 5); + $content_negotation = new \Drupal\Core\ContentNegotiation(); - $dispatcher->addSubscriber(new \Drupal\Core\EventSubscriber\RouterListener($matcher)); + $dispatcher->addSubscriber(new \Symfony\Component\HttpKernel\EventListener\RouterListener($matcher)); $dispatcher->addSubscriber(new \Drupal\Core\EventSubscriber\ViewSubscriber($content_negotation)); $dispatcher->addSubscriber(new \Drupal\Core\EventSubscriber\AccessSubscriber()); $dispatcher->addSubscriber(new \Drupal\Core\EventSubscriber\MaintenanceModeSubscriber()); @@ -68,6 +83,7 @@ class CoreBundle extends Bundle $dispatcher->addSubscriber(new \Drupal\Core\EventSubscriber\FinishResponseSubscriber()); $dispatcher->addSubscriber(new \Drupal\Core\EventSubscriber\RequestCloseSubscriber()); $dispatcher->addSubscriber(new \Drupal\Core\EventSubscriber\ConfigGlobalOverrideSubscriber()); + $dispatcher->addSubscriber(new \Drupal\Core\EventSubscriber\RouteProcessorSubscriber()); $container->set('content_negotiation', $content_negotation); $dispatcher->addSubscriber(\Drupal\Core\ExceptionController::getExceptionListener($container)); diff --git a/core/lib/Drupal/Core/EventSubscriber/RouteProcessorSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/RouteProcessorSubscriber.php new file mode 100644 index 0000000..cba2fce --- /dev/null +++ b/core/lib/Drupal/Core/EventSubscriber/RouteProcessorSubscriber.php @@ -0,0 +1,48 @@ +getRequest(); + + if (!$request->attributes->has('_controller') && $request->attributes->has('_content')) { + $request->attributes->set('_controller', '\Drupal\Core\HtmlPageController::content'); + } + } + + /** + * Registers the methods in this class that should be listeners. + * + * @return array + * An array of event listener definitions. + */ + static function getSubscribedEvents() { + // The RouterListener has priority 32, and we need to run after that. + $events[KernelEvents::REQUEST][] = array('onRequestSetController', 30); + + return $events; + } +} diff --git a/core/lib/Drupal/Core/EventSubscriber/ViewSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/ViewSubscriber.php index 637d48d..cb3aeea 100644 --- a/core/lib/Drupal/Core/EventSubscriber/ViewSubscriber.php +++ b/core/lib/Drupal/Core/EventSubscriber/ViewSubscriber.php @@ -10,6 +10,7 @@ namespace Drupal\Core\EventSubscriber; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -44,15 +45,31 @@ class ViewSubscriber implements EventSubscriberInterface { */ public function onView(GetResponseForControllerResultEvent $event) { - $request = $event->getRequest(); - - $method = 'on' . $this->negotiation->getContentType($request); - - if (method_exists($this, $method)) { - $event->setResponse($this->$method($event)); + // For a master request, we process the result and wrap it as needed. + // For a subrequest, all we want is the string value. We assume that + // is just an HTML string from a controller, so wrap that into a response + // object. The subrequest's response will get dissected and placed into + // the larger page as needed. + if ($event->getRequestType() == HttpKernelInterface::MASTER_REQUEST) { + $request = $event->getRequest(); + + $method = 'on' . $this->negotiation->getContentType($request); + + if (method_exists($this, $method)) { + $event->setResponse($this->$method($event)); + } + else { + $event->setResponse(new Response('Unsupported Media Type', 415)); + } } else { - $event->setResponse(new Response('Unsupported Media Type', 415)); + $page_result = $event->getControllerResult(); + if (!is_array($page_result)) { + $page_result = array( + '#markup' => $page_result, + ); + } + $event->setResponse(new Response(drupal_render_page($page_result))); } } diff --git a/core/lib/Drupal/Core/HtmlPageController.php b/core/lib/Drupal/Core/HtmlPageController.php new file mode 100644 index 0000000..863f33f --- /dev/null +++ b/core/lib/Drupal/Core/HtmlPageController.php @@ -0,0 +1,63 @@ +container = $container; + } + + /** + * Controller method for generic HTML pages. + * + * @param Request $request + * The request object. + * @param type $_content + * The body content callable that contains the body region of this page. + * @return \Symfony\Component\HttpFoundation\Response + */ + public function content(Request $request, $_content) { + + // @todo When we have a Generator, we can replace the forward() call with + // a render() call, which would handle ESI and hInclude as well. That will + // require an _internal route. For examples, see: + // https://github.com/symfony/symfony/blob/master/src/Symfony/Bundle/FrameworkBundle/Resources/config/routing/internal.xml + // https://github.com/symfony/symfony/blob/master/src/Symfony/Bundle/FrameworkBundle/Controller/InternalController.php + $attributes = $request->attributes; + $controller = $_content; + $attributes->remove('system_path'); + $attributes->remove('_content'); + $response = $this->container->get('http_kernel')->forward($controller, $attributes->all(), $request->query->all()); + + $page_content = $response->getContent(); + + return new Response(drupal_render_page($page_content)); + } +} diff --git a/core/lib/Drupal/Core/LegacyUrlMatcher.php b/core/lib/Drupal/Core/LegacyUrlMatcher.php index 8828f36..48987a9 100644 --- a/core/lib/Drupal/Core/LegacyUrlMatcher.php +++ b/core/lib/Drupal/Core/LegacyUrlMatcher.php @@ -9,13 +9,14 @@ namespace Drupal\Core; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Exception\ResourceNotFoundException; -use Symfony\Component\Routing\Matcher\UrlMatcherInterface; +use Symfony\Component\Routing\Matcher\RequestMatcherInterface; +use Symfony\Component\Routing\RequestContextAwareInterface; use Symfony\Component\Routing\RequestContext; /** * UrlMatcher matches URL based on a set of routes. */ -class LegacyUrlMatcher implements UrlMatcherInterface { +class LegacyUrlMatcher implements RequestMatcherInterface, RequestContextAwareInterface { /** * The request context for this matcher. @@ -98,8 +99,8 @@ class LegacyUrlMatcher implements UrlMatcherInterface { * * @api */ - public function match($pathinfo) { - if ($router_item = $this->matchDrupalItem($pathinfo)) { + public function matchRequest(Request $request) { + if ($router_item = $this->matchDrupalItem($request->attributes->get('system_path'))) { $ret = $this->convertDrupalItem($router_item); // Stash the router item in the attributes while we're transitioning. $ret['drupal_menu_item'] = $router_item; diff --git a/core/lib/Drupal/Core/Routing/ChainMatcher.php b/core/lib/Drupal/Core/Routing/ChainMatcher.php new file mode 100644 index 0000000..cf8a66d --- /dev/null +++ b/core/lib/Drupal/Core/Routing/ChainMatcher.php @@ -0,0 +1,159 @@ +context = new RequestContext(); + } + + /** + * Sets the request context. + * + * This method is just to satisfy the interface, and is largely vestigial. + * The request context object does not contain the information we need, so + * we will use the original request object. + * + * @param Symfony\Component\Routing\RequestContext $context + * The context. + * + * @api + */ + public function setContext(RequestContext $context) { + $this->context = $context; + } + + /** + * Gets the request context. + * + * This method is just to satisfy the interface, and is largely vestigial. + * The request context object does not contain the information we need, so + * we will use the original request object. + * + * @return Symfony\Component\Routing\RequestContext + * The context. + */ + public function getContext() { + return $this->context; + } + + /** + * Matches a request against all queued matchers. + * + * @param Request $request The request to match + * + * @return array An array of parameters + * + * @throws ResourceNotFoundException If no matching resource could be found + * @throws MethodNotAllowedException If a matching resource was found but the request method is not allowed + */ + public function matchRequest(Request $request) { + $methodNotAllowed = null; + + foreach ($this->all() as $matcher) { + try { + return $matcher->matchRequest($request); + } catch (ResourceNotFoundException $e) { + // Needs special care + } catch (MethodNotAllowedException $e) { + $methodNotAllowed = $e; + } + } + + throw $methodNotAllowed ?: new ResourceNotFoundException("None of the matchers in the chain matched this request."); + } + + /** + * Adds a Matcher to the index. + * + * @param MatcherInterface $matcher + * The matcher to add. + * @param int $priority + * The priority of the matcher. Higher number matchers will be checked + * first. + */ + public function add(RequestMatcherInterface $matcher, $priority = 0) { + if (empty($this->matchers[$priority])) { + $this->matchers[$priority] = array(); + } + + $this->matchers[$priority][] = $matcher; + $this->sortedMatchers = array(); + } + + /** + * Sorts the matchers and flattens them. + * + * @return array + * An array of RequestMatcherInterface objects. + */ + public function all() { + if (empty($this->sortedMatchers)) { + $this->sortedMatchers = $this->sortMatchers(); + } + + return $this->sortedMatchers; + } + + /** + * Sort matchers by priority. + * + * The highest priority number is the highest priority (reverse sorting). + * + * @return \Symfony\Component\Routing\RequestMatcherInterface[] + * An array of Matcher objects in the order they should be used. + */ + protected function sortMatchers() { + $sortedMatchers = array(); + krsort($this->matchers); + + foreach ($this->matchers as $matchers) { + $sortedMatchers = array_merge($sortedMatchers, $matchers); + } + + return $sortedMatchers; + } + +} diff --git a/core/lib/Drupal/Core/Routing/CompiledRoute.php b/core/lib/Drupal/Core/Routing/CompiledRoute.php new file mode 100644 index 0000000..1600e76 --- /dev/null +++ b/core/lib/Drupal/Core/Routing/CompiledRoute.php @@ -0,0 +1,163 @@ +route = $route; + $this->fit = $fit; + $this->patternOutline = $pattern_outline; + $this->numParts = $num_parts; + $this->regex = $regex; + } + + /** + * Returns the fit of this route + * + * See RouteCompiler for a definition of how the fit is calculated. + * + * @return int + * The fit of the route. + */ + public function getFit() { + return $this->fit; + } + + /** + * Returns the number of parts in this route's path. + * + * The string "foo/bar/baz" has 3 parts, regardless of how many of them are + * placeholders. + * + * @return int + * The number of parts in the path. + */ + public function getNumParts() { + return $this->numParts; + } + + /** + * Returns the pattern outline of this route. + * + * The pattern outline of a route is the path pattern of the route, but + * normalized such that all placeholders are replaced with %. + * + * @return string + * The normalized path pattern. + */ + public function getPatternOutline() { + return $this->patternOutline; + } + + /** + * Returns the placeholder regex. + * + * @return string + * The regex to locate placeholders in this pattern. + */ + public function getRegex() { + return $this->regex; + } + + /** + * Returns the Route instance. + * + * @return Route + * A Route instance + */ + public function getRoute() { + return $this->route; + } + + /** + * Returns the pattern. + * + * @return string The pattern + */ + public function getPattern() { + return $this->route->getPattern(); + } + + /** + * Returns the options. + * + * @return array The options + */ + public function getOptions() { + return $this->route->getOptions(); + } + + /** + * Returns the defaults. + * + * @return array The defaults + */ + public function getDefaults() { + return $this->route->getDefaults(); + } + + /** + * Returns the requirements. + * + * @return array The requirements + */ + public function getRequirements() { + return $this->route->getRequirements(); + } + +} + diff --git a/core/lib/Drupal/Core/Routing/FinalMatcherInterface.php b/core/lib/Drupal/Core/Routing/FinalMatcherInterface.php new file mode 100644 index 0000000..ae2bba0 --- /dev/null +++ b/core/lib/Drupal/Core/Routing/FinalMatcherInterface.php @@ -0,0 +1,34 @@ +routes = $collection; + + return $this; + } + + + public function matchRequest(Request $request) { + // Return whatever the first route in the collection is. + foreach ($this->routes as $name => $route) { + $path = '/' . $request->attributes->get('system_path'); + + $route->setOption('compiler_class', '\Drupal\Core\Routing\RouteCompiler'); + $compiled = $route->compile(); + + preg_match($compiled->getRegex(), $path, $matches); + + return array_merge($this->mergeDefaults($matches, $route->getDefaults()), array('_route' => $name)); + } + } + + /** + * Get merged default parameters. + * + * @param array $params + * The parameters + * @param array $defaults + * The defaults + * + * @return array + * Merged default parameters + */ + protected function mergeDefaults($params, $defaults) { + $parameters = $defaults; + foreach ($params as $key => $value) { + if (!is_int($key)) { + $parameters[$key] = $value; + } + } + + return $parameters; + } + +} + diff --git a/core/lib/Drupal/Core/Routing/HttpMethodMatcher.php b/core/lib/Drupal/Core/Routing/HttpMethodMatcher.php new file mode 100644 index 0000000..b0a1878 --- /dev/null +++ b/core/lib/Drupal/Core/Routing/HttpMethodMatcher.php @@ -0,0 +1,53 @@ +getMethod(); + + $collection = new RouteCollection(); + + foreach ($this->routes->all() as $name => $route) { + // _method could be a |-delimited list of allowed methods, or null. If + // null, we accept any method. + $allowed_methods = array_filter(explode('|', strtoupper($route->getRequirement('_method')))); + if (empty($allowed_methods) || in_array($method, $allowed_methods)) { + $collection->add($name, $route); + } + else { + // Build a list of methods that would have matched. Note that we only + // need to do this if a route doesn't match, because if even one route + // passes then we'll never throw the exception that needs this array. + $possible_methods += $allowed_methods; + } + } + + if (!count($collection->all())) { + throw new MethodNotAllowedException(array_unique($possible_methods)); + } + + return $collection; + } + +} + diff --git a/core/lib/Drupal/Core/Routing/InitialMatcherInterface.php b/core/lib/Drupal/Core/Routing/InitialMatcherInterface.php new file mode 100644 index 0000000..a08cb12 --- /dev/null +++ b/core/lib/Drupal/Core/Routing/InitialMatcherInterface.php @@ -0,0 +1,22 @@ +connection = $connection; + + $this->tableName = $table; + } + + /** + * Adds additional routes to be dumped. + * + * @param RouteCollection $routes + */ + public function addRoutes(RouteCollection $routes) { + if (empty($this->routes)) { + $this->routes = $routes; + } + else { + $this->routes->addCollection($routes); + } + } + + /** + * Dumps a set of routes to the router table in the database. + * + * Available options: + * + * * route_set: The route grouping that is being dumped. All existing + * routes with this route set will be deleted on dump. + * * base_class: The base class name + * + * @param $options array + * $options An array of options + */ + public function dump(array $options = array()) { + $options += array( + 'route_set' => '', + ); + + //$compiled = $this->compileRoutes($this->routes, $route_set); + + // Convert all of the routes into database records. + $insert = $this->connection->insert($this->tableName)->fields(array( + 'name', + 'route_set', + 'fit', + 'pattern', + 'pattern_outline', + 'number_parts', + 'route', + )); + + foreach ($this->routes as $name => $route) { + $route->setOption('compiler_class', '\Drupal\Core\Routing\RouteCompiler'); + $compiled = $route->compile(); + $values = array( + 'name' => $name, + 'route_set' => $options['route_set'], + 'fit' => $compiled->getFit(), + 'pattern' => $compiled->getPattern(), + 'pattern_outline' => $compiled->getPatternOutline(), + 'number_parts' => $compiled->getNumParts(), + // This is only temporary. We need to strip off the compiled route from + // route object in order to serialize it. Cloning strips off the + // compiled route object. Remove this once + // https://github.com/symfony/symfony/pull/4755 is merged and brought + // back downstream. + 'route' => serialize(clone($route)), + ); + $insert->values($values); + } + + // Delete any old records in this route set first, then insert the new ones. + // That avoids stale data. The transaction makes it atomic to avoid + // unstable router states due to random failures. + $txn = $this->connection->startTransaction(); + + $this->connection->delete($this->tableName) + ->condition('route_set', $options['route_set']) + ->execute(); + + $insert->execute(); + + // We want to reuse the dumper for multiple route sets, so on dump, flush + // the queued routes. + $this->routes = NULL; + + // Transaction ends here. + } + + /** + * Gets the routes to match. + * + * @return RouteCollection + * A RouteCollection instance representing all routes currently in the + * dumper. + */ + public function getRoutes() { + return $this->routes; + } + + /** + * Determines the fitness of the provided path. + * + * @param string $path + * The path whose fitness we want. + * + * @return int + * The fitness of the path, as an integer. + */ + public function getFit($path) { + $fit = 0; + + $parts = explode('/', $path, static::MAX_PARTS); + foreach ($parts as $k => $part) { + if (strpos($part, '{') === FALSE) { + $fit |= 1 << ($slashes - $k); + } + } + + return $fit; + } +} + diff --git a/core/lib/Drupal/Core/Routing/NestedMatcher.php b/core/lib/Drupal/Core/Routing/NestedMatcher.php new file mode 100644 index 0000000..db8a429 --- /dev/null +++ b/core/lib/Drupal/Core/Routing/NestedMatcher.php @@ -0,0 +1,149 @@ +partialMatchers[] = $matcher; + + return $this; + } + + /** + * Sets the final matcher for the matching plan. + * + * @param UrlMatcherInterface $final + * The matcher that will be called last to ensure only a single route is + * found. + * + * @return NestedMatcherInterface + * The current matcher. + */ + public function setFinalMatcher(FinalMatcherInterface $final) { + $this->finalMatcher = $final; + + return $this; + } + + /** + * Sets the first matcher for the matching plan. + * + * Partial matchers will be run in the order in which they are added. + * + * @param InitialMatcherInterface $matcher + * An initial matcher. It is responsible for its own configuration and + * initial route collection + * + * @return NestedMatcherInterface + * The current matcher. + */ + public function setInitialMatcher(InitialMatcherInterface $initial) { + $this->initialMatcher = $initial; + + return $this; + } + + /** + * Tries to match a request with a set of routes. + * + * If the matcher can not find information, it must throw one of the exceptions documented + * below. + * + * @param Request $request The request to match + * + * @return array An array of parameters + * + * @throws ResourceNotFoundException If no matching resource could be found + * @throws MethodNotAllowedException If a matching resource was found but the request method is not allowed + */ + public function matchRequest(Request $request) { + $collection = $this->initialMatcher->matchRequestPartial($request); + + foreach ($this->partialMatchers as $matcher) { + if ($collection) { + $matcher->setCollection($collection); + } + $collection = $matcher->matchRequestPartial($request); + } + + $attributes = $this->finalMatcher->setCollection($collection)->matchRequest($request); + + return $attributes; + } + + /** + * Sets the request context. + * + * This method is unused. It is here only to satisfy the interface. + * + * @param RequestContext $context The context + */ + public function setContext(RequestContext $context) { + $this->context = $context; + } + + /** + * Gets the request context. + * + * This method is unused. It is here only to satisfy the interface. + * + * @return RequestContext The context + */ + public function getContext() { + return $this->context; + } + +} + diff --git a/core/lib/Drupal/Core/Routing/NestedMatcherInterface.php b/core/lib/Drupal/Core/Routing/NestedMatcherInterface.php new file mode 100644 index 0000000..cd55d32 --- /dev/null +++ b/core/lib/Drupal/Core/Routing/NestedMatcherInterface.php @@ -0,0 +1,50 @@ +routes = $collection; + + return $this; + } + +} diff --git a/core/lib/Drupal/Core/Routing/PartialMatcherInterface.php b/core/lib/Drupal/Core/Routing/PartialMatcherInterface.php new file mode 100644 index 0000000..1b234e8 --- /dev/null +++ b/core/lib/Drupal/Core/Routing/PartialMatcherInterface.php @@ -0,0 +1,34 @@ +connection = $connection; + $this->tableName = $table; + } + + /** + * Matches a request against multiple routes. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * A Request object against which to match. + * + * @return \Symfony\Component\Routing\RouteCollection + * A RouteCollection of matched routes. + */ + public function matchRequestPartial(Request $request) { + + $path = rtrim($request->getPathInfo(), '/'); + + $parts = array_slice(array_filter(explode('/', $path)), 0, MatcherDumper::MAX_PARTS); + + $ancestors = $this->getCandidateOutlines($parts); + + $routes = $this->connection->query("SELECT name, route FROM {{$this->tableName}} WHERE pattern_outline IN (:patterns) ORDER BY fit", array( + ':patterns' => $ancestors, + )) + ->fetchAllKeyed(); + + $collection = new RouteCollection(); + foreach ($routes as $name => $route) { + $route = unserialize($route); + if (preg_match($route->compile()->getRegex(), $path, $matches)) { + $collection->add($name, $route); + } + } + + if (!count($collection->all())) { + throw new ResourceNotFoundException(); + } + + return $collection; + } + + /** + * Returns an array of path pattern outlines that could match the path parts. + * + * @param array $parts + * The parts of the path for which we want candidates. + * @return array + * An array of outlines that could match the specified path parts. + */ + public function getCandidateOutlines(array $parts) { + $number_parts = count($parts); + $ancestors = array(); + $length = $number_parts - 1; + $end = (1 << $number_parts) - 1; + + // The highest possible mask is a 1 bit for every part of the path. We will + // check every value down from there to generate a possible outline. + $masks = range($end, pow($number_parts - 1, 2)); + + // Only examine patterns that actually exist as router items (the masks). + foreach ($masks as $i) { + if ($i > $end) { + // Only look at masks that are not longer than the path of interest. + continue; + } + elseif ($i < (1 << $length)) { + // We have exhausted the masks of a given length, so decrease the length. + --$length; + } + $current = ''; + for ($j = $length; $j >= 0; $j--) { + // Check the bit on the $j offset. + if ($i & (1 << $j)) { + // Bit one means the original value. + $current .= $parts[$length - $j]; + } + else { + // Bit zero means means wildcard. + $current .= '%'; + } + // Unless we are at offset 0, add a slash. + if ($j) { + $current .= '/'; + } + } + $ancestors[] = '/' . $current; + } + return $ancestors; + } +} + diff --git a/core/lib/Drupal/Core/Routing/RouteBuilder.php b/core/lib/Drupal/Core/Routing/RouteBuilder.php new file mode 100644 index 0000000..a1d8daf --- /dev/null +++ b/core/lib/Drupal/Core/Routing/RouteBuilder.php @@ -0,0 +1,35 @@ +dumper = $dumper; + } + + public function rebuild() { + // We need to manually call each module so that we can know which module + // a given item came from. + + foreach (module_implements('route_info') as $module) { + $routes = call_user_func($module . '_route_info'); + drupal_alter('router_info', $routes, $module); + $this->dumper->addRoutes($routes); + $this->dumper->dump(array('route_set' => $module)); + } + } + +} diff --git a/core/lib/Drupal/Core/Routing/RouteCompiler.php b/core/lib/Drupal/Core/Routing/RouteCompiler.php new file mode 100644 index 0000000..0564a45 --- /dev/null +++ b/core/lib/Drupal/Core/Routing/RouteCompiler.php @@ -0,0 +1,239 @@ +getPathWithoutDefaults($route); + + $fit = $this->getFit($stripped_path); + + $pattern_outline = $this->getPatternOutline($stripped_path); + + $num_parts = count(explode('/', trim($pattern_outline, '/'))); + + $regex = $this->getRegex($route, $route->getPattern()); + + return new CompiledRoute($route, $fit, $pattern_outline, $num_parts, $regex); + } + + /** + * Generates a regular expression that will match this pattern. + * + * This regex can be used in preg_match() to extract values inside {}. + * + * This algorithm was lifted directly from Symfony's RouteCompiler class. + * It is not factored out nicely there, so we cannot simply subclass it. + * @todo Refactor Symfony's RouteCompiler so that it's useful to subclass. + * + * @param \Symfony\Component\Routing\Route $route + * The route object. + * @param string $pattern + * The pattern for which we want a matching regex. + * @return type + * @throws \LogicException + */ + public function getRegex(Route $route, $pattern) { + $len = strlen($pattern); + $tokens = array(); + $variables = array(); + $pos = 0; + preg_match_all('#.\{(\w+)\}#', $pattern, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER); + foreach ($matches as $match) { + if ($text = substr($pattern, $pos, $match[0][1] - $pos)) { + $tokens[] = array('text', $text); + } + + $pos = $match[0][1] + strlen($match[0][0]); + $var = $match[1][0]; + + if ($req = $route->getRequirement($var)) { + $regexp = $req; + } + else { + // Use the character preceding the variable as a separator + $separators = array($match[0][0][0]); + + if ($pos !== $len) { + // Use the character following the variable as the separator when available + $separators[] = $pattern[$pos]; + } + $regexp = sprintf('[^%s]+', preg_quote(implode('', array_unique($separators)), self::REGEX_DELIMITER)); + } + + $tokens[] = array('variable', $match[0][0][0], $regexp, $var); + + if (in_array($var, $variables)) { + throw new \LogicException(sprintf('Route pattern "%s" cannot reference variable name "%s" more than once.', $route->getPattern(), $var)); + } + + $variables[] = $var; + } + + if ($pos < $len) { + $tokens[] = array('text', substr($pattern, $pos)); + } + + // find the first optional token + $firstOptional = INF; + for ($i = count($tokens) - 1; $i >= 0; $i--) { + $token = $tokens[$i]; + if ('variable' === $token[0] && $route->hasDefault($token[3])) { + $firstOptional = $i; + } else { + break; + } + } + + // compute the matching regexp + $regexp = ''; + for ($i = 0, $nbToken = count($tokens); $i < $nbToken; $i++) { + $regexp .= $this->computeRegexp($tokens, $i, $firstOptional); + } + + return self::REGEX_DELIMITER.'^'.$regexp.'$'.self::REGEX_DELIMITER.'s'; + } + + /** + * Computes the regexp used to match a specific token. It can be static text or a subpattern. + * + * @param array $tokens The route tokens + * @param integer $index The index of the current token + * @param integer $firstOptional The index of the first optional token + * + * @return string The regexp pattern for a single token + */ + private function computeRegexp(array $tokens, $index, $firstOptional) { + $token = $tokens[$index]; + if ('text' === $token[0]) { + // Text tokens + return preg_quote($token[1], self::REGEX_DELIMITER); + } else { + // Variable tokens + if (0 === $index && 0 === $firstOptional) { + // When the only token is an optional variable token, the separator is required + return sprintf('%s(?<%s>%s)?', preg_quote($token[1], self::REGEX_DELIMITER), $token[3], $token[2]); + } else { + $regexp = sprintf('%s(?<%s>%s)', preg_quote($token[1], self::REGEX_DELIMITER), $token[3], $token[2]); + if ($index >= $firstOptional) { + // Enclose each optional token in a subpattern to make it optional. + // "?:" means it is non-capturing, i.e. the portion of the subject string that + // matched the optional subpattern is not passed back. + $regexp = "(?:$regexp"; + $nbTokens = count($tokens); + if ($nbTokens - 1 == $index) { + // Close the optional subpatterns + $regexp .= str_repeat(")?", $nbTokens - $firstOptional - (0 === $firstOptional ? 1 : 0)); + } + } + + return $regexp; + } + } + } + + + /** + * Returns the pattern outline. + * + * The pattern outline is the path pattern but normalized so that all + * placeholders are equal strings and default values are removed. + * + * @param string $path + * The path for which we want the normalized outline. + * + * @return string + * The path pattern outline. + */ + public function getPatternOutline($path) { + return preg_replace('#\{\w+\}#', '%', $path); + } + + /** + * Determines the fitness of the provided path. + * + * @param string $path + * The path whose fitness we want. + * + * @return int + * The fitness of the path, as an integer. + */ + public function getFit($path) { + $parts = explode('/', trim($path, '/'), static::MAX_PARTS); + $number_parts = count($parts); + // We store the highest index of parts here to save some work in the fit + // calculation loop. + $slashes = $number_parts - 1; + + $fit = 0; + foreach ($parts as $k => $part) { + if (strpos($part, '{') === FALSE) { + $fit |= 1 << ($slashes - $k); + } + } + + return $fit; + } + + /** + * Returns the path of the route, without placeholders with a default value. + * + * When computing the path outline and fit, we want to skip default-value + * placeholders. If we didn't, the path would never match. Note that this + * only works for placeholders at the end of the path. Infix placeholders + * with default values don't make sense anyway, so that should not be a + * problem. + * + * @param \Symfony\Component\Routing\Route $route + * + * @return string + * The path string, stripped of placeholders that have default values. + */ + protected function getPathWithoutDefaults(Route $route) { + $path = $route->getPattern(); + $defaults = $route->getDefaults(); + + // Remove placeholders with default values from the outline, so that they + // will still match. + $remove = array_map(function($a) { + return '/{' . $a . '}'; + }, array_keys($defaults)); + $path = str_replace($remove, '', $path); + + return $path; + } + +} + diff --git a/core/modules/system/lib/Drupal/system/FileDownload.php b/core/modules/system/lib/Drupal/system/FileDownload.php new file mode 100644 index 0000000..b55872c --- /dev/null +++ b/core/modules/system/lib/Drupal/system/FileDownload.php @@ -0,0 +1,54 @@ + 'Chain matcher tests', + 'description' => 'Confirm that the chain matcher is working correctly.', + 'group' => 'Routing', + ); + } + + /** + * Confirms that the expected exception is thrown. + */ + public function testMethodNotAllowed() { + + $chain = new ChainMatcher(); + + $method_not_allowed = new MockMatcher(function(Request $request) { + throw new MethodNotAllowedException(array('POST')); + }); + + try { + $chain->add($method_not_allowed); + $chain->matchRequest(Request::create('my/path')); + } + catch (MethodNotAllowedException $e) { + $this->pass('Correct exception thrown.'); + } + catch (Exception $e) { + $this->fail('Incorrect exception thrown: ' . get_class($e)); + } + } + + /** + * Confirms that the expected exception is thrown. + */ + public function testRequestNotFound() { + + $chain = new ChainMatcher(); + + $resource_not_found = new MockMatcher(function(Request $request) { + throw new ResourceNotFoundException(); + }); + + try { + $chain->add($resource_not_found); + $chain->matchRequest(Request::create('my/path')); + } + catch (ResourceNotFoundException $e) { + $this->pass('Correct exception thrown.'); + } + catch (Exception $e) { + $this->fail('Incorrect exception thrown: ' . get_class($e)); + } + } + + /** + * Confirms that the expected exception is thrown. + */ + public function testRequestFound() { + + $chain = new ChainMatcher(); + + $method_not_allowed = new MockMatcher(function(Request $request) { + throw new MethodNotAllowedException(array('POST')); + }); + + $resource_not_found = new MockMatcher(function(Request $request) { + throw new ResourceNotFoundException(); + }); + + $found_data = new MockMatcher(function(Request $request) { + return array('_controller' => 'foo'); + }); + + try { + $chain->add($method_not_allowed); + $chain->add($resource_not_found); + $chain->add($found_data); + $request = Request::create('my/path'); + $attributes = $chain->matchRequest($request); + $this->assertEqual($attributes['_controller'], 'foo', 'Correct attributes returned.'); + } + catch (Exception $e) { + $this->fail('Exception thrown when a match should have been successful: ' . get_class($e)); + } + } + +} diff --git a/core/modules/system/lib/Drupal/system/Tests/Routing/FirstEntryFinalMatcherTest.php b/core/modules/system/lib/Drupal/system/Tests/Routing/FirstEntryFinalMatcherTest.php new file mode 100644 index 0000000..0f17477 --- /dev/null +++ b/core/modules/system/lib/Drupal/system/Tests/Routing/FirstEntryFinalMatcherTest.php @@ -0,0 +1,116 @@ + 'FirstEntryFinalMatcher tests', + 'description' => 'Confirm that the FirstEntryFinalMatcher is working properly.', + 'group' => 'Routing', + ); + } + + function __construct($test_id = NULL) { + parent::__construct($test_id); + + $this->fixtures = new RoutingFixtures(); + } + public function setUp() { + parent::setUp(); + } + + /** + * Confirms the final matcher returns correct attributes for static paths. + */ + public function testFinalMatcherStatic() { + + $collection = new RouteCollection(); + $collection->add('route_a', new Route('/path/one', array( + '_controller' => 'foo', + ))); + + $request = Request::create('/path/one', 'GET'); + + $matcher = new FirstEntryFinalMatcher(); + $matcher->setCollection($collection); + $attributes = $matcher->matchRequest($request); + + $this->assertEqual($attributes['_route'], 'route_a', 'The correct matching route was found.'); + $this->assertEqual($attributes['_controller'], 'foo', 'The correct controller was found.'); + } + + /** + * Confirms the final matcher returns correct attributes for pattern paths. + */ + public function testFinalMatcherPattern() { + + $collection = new RouteCollection(); + $collection->add('route_a', new Route('/path/one/{value}', array( + '_controller' => 'foo', + ))); + + $request = Request::create('/path/one/narf', 'GET'); + $request->attributes->set('system_path', 'path/one/narf'); + + $matcher = new FirstEntryFinalMatcher(); + $matcher->setCollection($collection); + $attributes = $matcher->matchRequest($request); + + $this->assertEqual($attributes['_route'], 'route_a', 'The correct matching route was found.'); + $this->assertEqual($attributes['_controller'], 'foo', 'The correct controller was found.'); + $this->assertEqual($attributes['value'], 'narf', 'Required placeholder value found.'); + } + + /** + * Confirms the final matcher returns correct attributes with default values. + */ + public function testFinalMatcherPatternDefalts() { + + $collection = new RouteCollection(); + $collection->add('route_a', new Route('/path/one/{value}', array( + '_controller' => 'foo', + 'value' => 'poink' + ))); + + $request = Request::create('/path/one', 'GET'); + $request->attributes->set('system_path', 'path/one'); + + $matcher = new FirstEntryFinalMatcher(); + $matcher->setCollection($collection); + $attributes = $matcher->matchRequest($request); + + $this->assertEqual($attributes['_route'], 'route_a', 'The correct matching route was found.'); + $this->assertEqual($attributes['_controller'], 'foo', 'The correct controller was found.'); + $this->assertEqual($attributes['value'], 'poink', 'Optional placeholder value used default.'); + } +} + diff --git a/core/modules/system/lib/Drupal/system/Tests/Routing/HttpMethodMatcherTest.php b/core/modules/system/lib/Drupal/system/Tests/Routing/HttpMethodMatcherTest.php new file mode 100644 index 0000000..962abfc --- /dev/null +++ b/core/modules/system/lib/Drupal/system/Tests/Routing/HttpMethodMatcherTest.php @@ -0,0 +1,109 @@ + 'Partial matcher HTTP Method tests', + 'description' => 'Confirm that the Http Method partial matcher is functioning properly.', + 'group' => 'Routing', + ); + } + + function __construct($test_id = NULL) { + parent::__construct($test_id); + + $this->fixtures = new RoutingFixtures(); + } + public function setUp() { + parent::setUp(); + } + + /** + * Confirms that the HttpMethod matcher matches properly. + */ + public function testFilterRoutes() { + + $matcher = new HttpMethodMatcher(); + $matcher->setCollection($this->fixtures->sampleRouteCollection()); + + $routes = $matcher->matchRequestPartial(Request::create('path/one', 'GET')); + + $this->assertEqual(count($routes->all()), 4, 'The correct number of routes was found.'); + $this->assertNotNull($routes->get('route_a'), 'The first matching route was found.'); + $this->assertNull($routes->get('route_b'), 'The non-matching route was not found.'); + $this->assertNotNull($routes->get('route_c'), 'The second matching route was found.'); + $this->assertNotNull($routes->get('route_d'), 'The all-matching route was found.'); + $this->assertNotNull($routes->get('route_e'), 'The multi-matching route was found.'); + } + + /** + * Confirms we can nest multiple partial matchers. + */ + public function testNestedMatcher() { + + $matcher = new NestedMatcher(); + + $matcher->setInitialMatcher(new MockPathMatcher($this->fixtures->sampleRouteCollection())); + $matcher->addPartialMatcher(new HttpMethodMatcher()); + $matcher->setFinalMatcher(new FirstEntryFinalMatcher()); + + $request = Request::create('/path/one', 'GET'); + + $attributes = $matcher->matchRequest($request); + + $this->assertEqual($attributes['_route'], 'route_a', 'The correct matching route was found.'); + } + + /** + * Confirms that the HttpMethod matcher throws an exception for no-route. + */ + public function testNoRouteFound() { + $matcher = new HttpMethodMatcher(); + + // Remove the sample route that would match any method. + $routes = $this->fixtures->sampleRouteCollection(); + $routes->remove('route_d'); + + $matcher->setCollection($routes); + + try { + $routes = $matcher->matchRequestPartial(Request::create('path/one', 'DELETE')); + $this->fail(t('No exception was thrown.')); + } + catch (Exception $e) { + $this->assertTrue($e instanceof MethodNotAllowedException, 'The correct exception was thrown.'); + } + + } +} + diff --git a/core/modules/system/lib/Drupal/system/Tests/Routing/MatcherDumperTest.php b/core/modules/system/lib/Drupal/system/Tests/Routing/MatcherDumperTest.php new file mode 100644 index 0000000..7f6f312 --- /dev/null +++ b/core/modules/system/lib/Drupal/system/Tests/Routing/MatcherDumperTest.php @@ -0,0 +1,144 @@ + 'Dumper tests', + 'description' => 'Confirm that the matcher dumper is functioning properly.', + 'group' => 'Routing', + ); + } + + function __construct($test_id = NULL) { + parent::__construct($test_id); + + $this->fixtures = new RoutingFixtures(); + } + + function setUp() { + parent::setUp(); + } + + /** + * Confirms that the dumper can be instantiated successfuly. + */ + function testCreate() { + $connection = Database::getConnection(); + $dumper= new MatcherDumper($connection); + + $class_name = 'Drupal\Core\Routing\MatcherDumper'; + $this->assertTrue($dumper instanceof $class_name, 'Dumper created successfully'); + } + + /** + * Confirms that we can add routes to the dumper. + */ + function testAddRoutes() { + $connection = Database::getConnection(); + $dumper= new MatcherDumper($connection); + + $route = new Route('test'); + $collection = new RouteCollection(); + $collection->add('test_route', $route); + + $dumper->addRoutes($collection); + + $dumper_routes = $dumper->getRoutes()->all(); + $collection_routes = $collection->all(); + + foreach ($dumper_routes as $name => $route) { + $this->assertEqual($route->getPattern(), $collection_routes[$name]->getPattern(), 'Routes match'); + } + } + + /** + * Confirms that we can add routes to the dumper when it already has some. + */ + function testAddAdditionalRoutes() { + $connection = Database::getConnection(); + $dumper= new MatcherDumper($connection); + + $route = new Route('test'); + $collection = new RouteCollection(); + $collection->add('test_route', $route); + $dumper->addRoutes($collection); + + $route = new Route('test2'); + $collection2 = new RouteCollection(); + $collection2->add('test_route2', $route); + $dumper->addRoutes($collection2); + + // Merge the two collections together so we can test them. + $collection->addCollection(clone $collection2); + + $dumper_routes = $dumper->getRoutes()->all(); + $collection_routes = $collection->all(); + + $success = TRUE; + foreach ($collection_routes as $name => $route) { + if (empty($dumper_routes[$name])) { + $success = FALSE; + $this->fail(t('Not all routes found in the dumper.')); + } + } + + if ($success) { + $this->pass('All routes found in the dumper.'); + } + } + + /** + * Confirm that we can dump a route collection to the database. + */ + public function testDump() { + $connection = Database::getConnection(); + $dumper= new MatcherDumper($connection, 'test_routes'); + + $route = new Route('/test/{my}/path'); + $route->setOption('compiler_class', 'Drupal\Core\Routing\RouteCompiler'); + $collection = new RouteCollection(); + $collection->add('test_route', $route); + + $dumper->addRoutes($collection); + + $this->fixtures->createTables($connection); + + $dumper->dump(array('route_set' => 'test')); + + $record = $connection->query("SELECT * FROM {test_routes} WHERE name= :name", array(':name' => 'test_route'))->fetchObject(); + + $loaded_route = unserialize($record->route); + + $this->assertEqual($record->name, 'test_route', 'Dumped route has correct name.'); + $this->assertEqual($record->pattern, '/test/{my}/path', 'Dumped route has correct pattern.'); + $this->assertEqual($record->pattern_outline, '/test/%/path', 'Dumped route has correct pattern outline.'); + $this->assertEqual($record->fit, 5 /* 101 in binary */, 'Dumped route has correct fit.'); + $this->assertTrue($loaded_route instanceof Route, 'Route object retrieved successfully.'); + + } +} diff --git a/core/modules/system/lib/Drupal/system/Tests/Routing/MockMatcher.php b/core/modules/system/lib/Drupal/system/Tests/Routing/MockMatcher.php new file mode 100644 index 0000000..6cd58fc --- /dev/null +++ b/core/modules/system/lib/Drupal/system/Tests/Routing/MockMatcher.php @@ -0,0 +1,35 @@ +matcher = $matcher; + } + + public function matchRequest(Request $request) { + $matcher = $this->matcher; + return $matcher($request); + } +} + diff --git a/core/modules/system/lib/Drupal/system/Tests/Routing/MockPathMatcher.php b/core/modules/system/lib/Drupal/system/Tests/Routing/MockPathMatcher.php new file mode 100644 index 0000000..b545ebe --- /dev/null +++ b/core/modules/system/lib/Drupal/system/Tests/Routing/MockPathMatcher.php @@ -0,0 +1,50 @@ +routes = $routes; + } + + /** + * Matches a request against multiple routes. + * + * @param Request $request + * A Request object against which to match. + * + * @return RouteCollection + * A RouteCollection of matched routes. + */ + public function matchRequestPartial(Request $request) { + // For now for testing we'll just do a straight string match. + + $path = $request->getPathInfo(); + + $return = new RouteCollection(); + + foreach ($this->routes as $name => $route) { + if ($route->getPattern() == $path) { + $return->add($name, $route); + } + } + + return $return; + } + + +} diff --git a/core/modules/system/lib/Drupal/system/Tests/Routing/NestedMatcherTest.php b/core/modules/system/lib/Drupal/system/Tests/Routing/NestedMatcherTest.php new file mode 100644 index 0000000..9c0f5de --- /dev/null +++ b/core/modules/system/lib/Drupal/system/Tests/Routing/NestedMatcherTest.php @@ -0,0 +1,69 @@ + 'NestedMatcher tests', + 'description' => 'Confirm that the NestedMatcher system is working properly.', + 'group' => 'Routing', + ); + } + + function __construct($test_id = NULL) { + parent::__construct($test_id); + + $this->fixtures = new RoutingFixtures(); + } + public function setUp() { + parent::setUp(); + } + + /** + * Confirms we can nest multiple partial matchers. + */ + public function testNestedMatcher() { + + $matcher = new NestedMatcher(); + + $matcher->setInitialMatcher(new MockPathMatcher($this->fixtures->sampleRouteCollection())); + $matcher->addPartialMatcher(new HttpMethodMatcher()); + $matcher->setFinalMatcher(new FirstEntryFinalMatcher()); + + $request = Request::create('/path/one', 'GET'); + + $attributes = $matcher->matchRequest($request); + + $this->assertEqual($attributes['_route'], 'route_a', 'The correct matching route was found.'); + } +} + diff --git a/core/modules/system/lib/Drupal/system/Tests/Routing/PathMatcherTest.php b/core/modules/system/lib/Drupal/system/Tests/Routing/PathMatcherTest.php new file mode 100644 index 0000000..10cfc82 --- /dev/null +++ b/core/modules/system/lib/Drupal/system/Tests/Routing/PathMatcherTest.php @@ -0,0 +1,304 @@ + 'Path matcher tests', + 'description' => 'Confirm that the path matching library is working correctly.', + 'group' => 'Routing', + ); + } + + function __construct($test_id = NULL) { + parent::__construct($test_id); + + $this->fixtures = new RoutingFixtures(); + } + + public function tearDown() { + $this->fixtures->dropTables(Database::getConnection()); + + parent::tearDown(); + } + + /** + * Confirms that the correct candidate outlines are generated. + */ + public function testCandidateOutlines() { + + $connection = Database::getConnection(); + $matcher = new PathMatcher($connection); + + $parts = array('node', '5', 'edit'); + + $candidates = $matcher->getCandidateOutlines($parts); + + //debug($candidates); + + $candidates = array_flip($candidates); + + $this->assertTrue(count($candidates) == 4, 'Correct number of candidates found'); + $this->assertTrue(array_key_exists('/node/5/edit', $candidates), 'First candidate found.'); + $this->assertTrue(array_key_exists('/node/5/%', $candidates), 'Second candidate found.'); + $this->assertTrue(array_key_exists('/node/%/edit', $candidates), 'Third candidate found.'); + $this->assertTrue(array_key_exists('/node/%/%', $candidates), 'Fourth candidate found.'); + } + + /** + * Confirms that we can find routes with the exact incoming path. + */ + function testExactPathMatch() { + $connection = Database::getConnection(); + $matcher = new PathMatcher($connection, 'test_routes'); + + $this->fixtures->createTables($connection); + + $dumper = new MatcherDumper($connection, 'test_routes'); + $dumper->addRoutes($this->fixtures->sampleRouteCollection()); + $dumper->dump(); + + $path = '/path/one'; + + $request = Request::create($path, 'GET'); + + $routes = $matcher->matchRequestPartial($request); + + foreach ($routes as $route) { + $this->assertEqual($route->getPattern(), $path, 'Found path has correct pattern'); + } + } + + /** + * Confirms that we can find routes whose pattern would match the request. + */ + function testOutlinePathMatch() { + $connection = Database::getConnection(); + $matcher = new PathMatcher($connection, 'test_routes'); + + $this->fixtures->createTables($connection); + + $dumper = new MatcherDumper($connection, 'test_routes'); + $dumper->addRoutes($this->fixtures->complexRouteCollection()); + $dumper->dump(); + + $path = '/path/1/one'; + + $request = Request::create($path, 'GET'); + + $routes = $matcher->matchRequestPartial($request); + + // All of the matching paths have the correct pattern. + foreach ($routes as $route) { + $this->assertEqual($route->compile()->getPatternOutline(), '/path/%/one', 'Found path has correct pattern'); + } + + $this->assertEqual(count($routes->all()), 2, 'The correct number of routes was found.'); + $this->assertNotNull($routes->get('route_a'), 'The first matching route was found.'); + $this->assertNotNull($routes->get('route_b'), 'The second matching route was not found.'); + } + + /** + * Confirms that a trailing slash on the request doesn't result in a 404. + */ + function testOutlinePathMatchTrailingSlash() { + $connection = Database::getConnection(); + $matcher = new PathMatcher($connection, 'test_routes'); + + $this->fixtures->createTables($connection); + + $dumper = new MatcherDumper($connection, 'test_routes'); + $dumper->addRoutes($this->fixtures->complexRouteCollection()); + $dumper->dump(); + + $path = '/path/1/one/'; + + $request = Request::create($path, 'GET'); + + $routes = $matcher->matchRequestPartial($request); + + // All of the matching paths have the correct pattern. + foreach ($routes as $route) { + $this->assertEqual($route->compile()->getPatternOutline(), '/path/%/one', 'Found path has correct pattern'); + } + + $this->assertEqual(count($routes->all()), 2, 'The correct number of routes was found.'); + $this->assertNotNull($routes->get('route_a'), 'The first matching route was found.'); + $this->assertNotNull($routes->get('route_b'), 'The second matching route was not found.'); + } + + /** + * Confirms that we can find routes whose pattern would match the request. + */ + function testOutlinePathMatchDefaults() { + $connection = Database::getConnection(); + $matcher = new PathMatcher($connection, 'test_routes'); + + $this->fixtures->createTables($connection); + + $collection = new RouteCollection(); + $collection->add('poink', new Route('/some/path/{value}', array( + 'value' => 'poink', + ))); + + $dumper = new MatcherDumper($connection, 'test_routes'); + $dumper->addRoutes($collection); + $dumper->dump(); + + $path = '/some/path'; + + $request = Request::create($path, 'GET'); + + try { + $routes = $matcher->matchRequestPartial($request); + + // All of the matching paths have the correct pattern. + foreach ($routes as $route) { + $compiled = $route->compile(); + $this->assertEqual($route->compile()->getPatternOutline(), '/some/path', 'Found path has correct pattern'); + } + + $this->assertEqual(count($routes->all()), 1, 'The correct number of routes was found.'); + $this->assertNotNull($routes->get('poink'), 'The first matching route was found.'); + } + catch (ResourceNotFoundException $e) { + $this->fail('No matching route found with default argument value.'); + } + } + + /** + * Confirms that we can find routes whose pattern would match the request. + */ + function testOutlinePathMatchDefaultsCollision() { + $connection = Database::getConnection(); + $matcher = new PathMatcher($connection, 'test_routes'); + + $this->fixtures->createTables($connection); + + $collection = new RouteCollection(); + $collection->add('poink', new Route('/some/path/{value}', array( + 'value' => 'poink', + ))); + $collection->add('narf', new Route('/some/path/here')); + + $dumper = new MatcherDumper($connection, 'test_routes'); + $dumper->addRoutes($collection); + $dumper->dump(); + + $path = '/some/path'; + + $request = Request::create($path, 'GET'); + + try { + $routes = $matcher->matchRequestPartial($request); + + // All of the matching paths have the correct pattern. + foreach ($routes as $route) { + $compiled = $route->compile(); + $this->assertEqual($route->compile()->getPatternOutline(), '/some/path', 'Found path has correct pattern'); + } + + $this->assertEqual(count($routes->all()), 1, 'The correct number of routes was found.'); + $this->assertNotNull($routes->get('poink'), 'The first matching route was found.'); + } + catch (ResourceNotFoundException $e) { + $this->fail('No matching route found with default argument value.'); + } + } + + /** + * Confirms that we can find routes whose pattern would match the request. + */ + function testOutlinePathMatchDefaultsCollision2() { + $connection = Database::getConnection(); + $matcher = new PathMatcher($connection, 'test_routes'); + + $this->fixtures->createTables($connection); + + $collection = new RouteCollection(); + $collection->add('poink', new Route('/some/path/{value}', array( + 'value' => 'poink', + ))); + $collection->add('narf', new Route('/some/path/here')); + + $dumper = new MatcherDumper($connection, 'test_routes'); + $dumper->addRoutes($collection); + $dumper->dump(); + + $path = '/some/path/here'; + + $request = Request::create($path, 'GET'); + + try { + $routes = $matcher->matchRequestPartial($request); + + // All of the matching paths have the correct pattern. + foreach ($routes as $route) { + $this->assertEqual($route->compile()->getPatternOutline(), '/some/path/here', 'Found path has correct pattern'); + } + + $this->assertEqual(count($routes->all()), 1, 'The correct number of routes was found.'); + $this->assertNotNull($routes->get('narf'), 'The first matching route was found.'); + } + catch (ResourceNotFoundException $e) { + $this->fail('No matching route found with default argument value.'); + } + } + + /** + * Confirm that an exception is thrown when no matching path is found. + */ + function testOutlinePathNoMatch() { + $connection = Database::getConnection(); + $matcher = new PathMatcher($connection, 'test_routes'); + + $this->fixtures->createTables($connection); + + $dumper = new MatcherDumper($connection, 'test_routes'); + $dumper->addRoutes($this->fixtures->complexRouteCollection()); + $dumper->dump(); + + $path = '/no/such/path'; + + $request = Request::create($path, 'GET'); + + try { + $routes = $matcher->matchRequestPartial($request); + $this->fail(t('No exception was thrown.')); + } + catch (Exception $e) { + $this->assertTrue($e instanceof ResourceNotFoundException, 'The correct exception was thrown.'); + } + + } + +} diff --git a/core/modules/system/lib/Drupal/system/Tests/Routing/RouteTest.php b/core/modules/system/lib/Drupal/system/Tests/Routing/RouteTest.php new file mode 100644 index 0000000..9e4ba63 --- /dev/null +++ b/core/modules/system/lib/Drupal/system/Tests/Routing/RouteTest.php @@ -0,0 +1,61 @@ + 'Routes', + 'description' => 'Confirm that route object is functioning properly.', + 'group' => 'Routing', + ); + } + + function setUp() { + parent::setUp(); + } + + /** + * Confirms that a route compiles properly with the necessary data. + */ + public function testCompilation() { + $route = new Route('/test/{something}/more'); + $route->setOption('compiler_class', 'Drupal\Core\Routing\RouteCompiler'); + $compiled = $route->compile(); + + $this->assertEqual($route, $compiled->getRoute(), 'Compiled route has the correct route object.'); + $this->assertEqual($compiled->getFit(), 5 /* That's 101 binary*/, 'The fit was correct.'); + $this->assertEqual($compiled->getPatternOutline(), '/test/%/more', 'The pattern outline was correct.'); + } + + /** + * Confirms that a compiled route with default values has the correct outline. + */ + public function testCompilationDefaultValue() { + // Because "here" has a default value, it should not factor into the + // outline or the fitness. + $route = new Route('/test/{something}/more/{here}', array( + 'here' => 'there', + )); + $route->setOption('compiler_class', 'Drupal\Core\Routing\RouteCompiler'); + $compiled = $route->compile(); + + $this->assertEqual($route, $compiled->getRoute(), 'Compiled route has the correct route object.'); + $this->assertEqual($compiled->getFit(), 5 /* That's 101 binary*/, 'The fit was correct.'); + $this->assertEqual($compiled->getPatternOutline(), '/test/%/more', 'The pattern outline was correct.'); + } + +} diff --git a/core/modules/system/lib/Drupal/system/Tests/Routing/RouterTest.php b/core/modules/system/lib/Drupal/system/Tests/Routing/RouterTest.php new file mode 100644 index 0000000..f5b4a18 --- /dev/null +++ b/core/modules/system/lib/Drupal/system/Tests/Routing/RouterTest.php @@ -0,0 +1,68 @@ + 'Integrated Router tests', + 'description' => 'Function Tests for the fully integrated routing system.', + 'group' => 'Routing', + ); + } + + /** + * Confirms that the router can get to a controller. + */ + public function testCanRoute() { + $this->drupalGet('router_test/test1'); + $this->assertRaw('test1', 'The correct string was returned because the route was successful.'); + } + + /** + * Confirms that our default controller logic works properly. + */ + public function testDefaultController() { + $this->drupalGet('router_test/test2'); + $this->assertRaw('test2', 'The correct string was returned because the route was successful.'); + $this->assertRaw('', 'Page markup was found.'); + } + + /** + * Confirms that placeholders in paths work correctly. + */ + public function testControllerPlaceholders() { + $value = $this->randomName(); + $this->drupalGet('router_test/test3/' . $value); + $this->assertRaw($value, 'The correct string was returned because the route was successful.'); + $this->assertRaw('', 'Page markup was found.'); + } + + /** + * Confirms that default placeholders in paths work correctly. + */ + public function testControllerPlaceholdersDefaultValues() { + $this->drupalGet('router_test/test4'); + $this->assertRaw('narf', 'The correct string was returned because the route was successful.'); + $this->assertRaw('', 'Page markup was found.'); + } + +} diff --git a/core/modules/system/lib/Drupal/system/Tests/Routing/RoutingFixtures.php b/core/modules/system/lib/Drupal/system/Tests/Routing/RoutingFixtures.php new file mode 100644 index 0000000..8937c75 --- /dev/null +++ b/core/modules/system/lib/Drupal/system/Tests/Routing/RoutingFixtures.php @@ -0,0 +1,167 @@ +routingTableDefinition(); + $schema = $connection->schema(); + + foreach ($tables as $name => $table) { + $schema->dropTable($name); + $schema->createTable($name, $table); + } + } + + public function dropTables(Connection $connection) { + $tables = $this->routingTableDefinition(); + $schema = $connection->schema(); + + foreach ($tables as $name => $table) { + $schema->dropTable($name); + } + } + + /** + * Returns a standard set of routes for testing. + * + * @return \Symfony\Component\Routing\RouteCollection + */ + public function sampleRouteCollection() { + $collection = new RouteCollection(); + + $route = new Route('path/one'); + $route->setRequirement('_method', 'GET'); + $collection->add('route_a', $route); + + $route = new Route('path/one'); + $route->setRequirement('_method', 'PUT'); + $collection->add('route_b', $route); + + $route = new Route('path/two'); + $route->setRequirement('_method', 'GET'); + $collection->add('route_c', $route); + + $route = new Route('path/three'); + $collection->add('route_d', $route); + + $route = new Route('path/two'); + $route->setRequirement('_method', 'GET|HEAD'); + $collection->add('route_e', $route); + + return $collection; + } + + /** + * Returns a complex set of routes for testing. + * + * @return \Symfony\Component\Routing\RouteCollection + */ + public function complexRouteCollection() { + $collection = new RouteCollection(); + + $route = new Route('/path/{thing}/one'); + $route->setRequirement('_method', 'GET'); + $collection->add('route_a', $route); + + $route = new Route('/path/{thing}/one'); + $route->setRequirement('_method', 'PUT'); + $collection->add('route_b', $route); + + $route = new Route('/somewhere/{item}/over/the/rainbow'); + $route->setRequirement('_method', 'GET'); + $collection->add('route_c', $route); + + $route = new Route('/another/{thing}/about/{item}'); + $collection->add('route_d', $route); + + $route = new Route('/path/add/one'); + $route->setRequirement('_method', 'GET|HEAD'); + $collection->add('route_e', $route); + + return $collection; + } + + public function routingTableDefinition() { + + $tables['test_routes'] = array( + 'description' => 'Maps paths to various callbacks (access, page and title)', + 'fields' => array( + 'name' => array( + 'description' => 'Primary Key: Machine name of this route', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'pattern' => array( + 'description' => 'The path pattern for this URI', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'pattern_outline' => array( + 'description' => 'The pattern', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'route_set' => array( + 'description' => 'The route set grouping to which a route belongs.', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'access_callback' => array( + 'description' => 'The callback which determines the access to this router path. Defaults to user_access.', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'access_arguments' => array( + 'description' => 'A serialized array of arguments for the access callback.', + 'type' => 'blob', + 'not null' => FALSE, + ), + 'fit' => array( + 'description' => 'A numeric representation of how specific the path is.', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + ), + 'number_parts' => array( + 'description' => 'Number of parts in this router path.', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'size' => 'small', + ), + 'route' => array( + 'description' => 'A serialized Route object', + 'type' => 'text', + ), + ), + 'indexes' => array( + 'fit' => array('fit'), + 'pattern_outline' => array('pattern_outline'), + 'route_set' => array('route_set'), + ), + 'primary key' => array('name'), + ); + + return $tables; + } +} diff --git a/core/modules/system/system.install b/core/modules/system/system.install index a6856ec..3a16394 100644 --- a/core/modules/system/system.install +++ b/core/modules/system/system.install @@ -1261,6 +1261,63 @@ function system_schema() { ), ); + $schema['router'] = array( + 'description' => 'Maps paths to various callbacks (access, page and title)', + 'fields' => array( + 'name' => array( + 'description' => 'Primary Key: Machine name of this route', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'pattern' => array( + 'description' => 'The path pattern for this URI', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'pattern_outline' => array( + 'description' => 'The pattern', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'route_set' => array( + 'description' => 'The route set grouping to which a route belongs.', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'fit' => array( + 'description' => 'A numeric representation of how specific the path is.', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + ), + 'route' => array( + 'description' => 'A serialized Route object', + 'type' => 'text', + ), + 'number_parts' => array( + 'description' => 'Number of parts in this router path.', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'size' => 'small', + ), + ), + 'indexes' => array( + 'fit' => array('fit'), + 'pattern_outline' => array('pattern_outline'), + 'route_set' => array('route_set'), + ), + 'primary key' => array('name'), + ); + $schema['semaphore'] = array( 'description' => 'Table for holding semaphores, locks, flags, etc. that cannot be stored as Drupal variables since they must not be cached.', 'fields' => array( @@ -1950,6 +2007,75 @@ function system_update_8019() { db_drop_table('registry_file'); } +/* + * Create the new routing table. + */ +function system_update_8020() { + + $tables['router'] = array( + 'description' => 'Maps paths to various callbacks (access, page and title)', + 'fields' => array( + 'name' => array( + 'description' => 'Primary Key: Machine name of this route', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'pattern' => array( + 'description' => 'The path pattern for this URI', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'pattern_outline' => array( + 'description' => 'The pattern', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'route_set' => array( + 'description' => 'The route set grouping to which a route belongs.', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'fit' => array( + 'description' => 'A numeric representation of how specific the path is.', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + ), + 'route' => array( + 'description' => 'A serialized Route object', + 'type' => 'text', + ), + 'number_parts' => array( + 'description' => 'Number of parts in this router path.', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'size' => 'small', + ), + ), + 'indexes' => array( + 'fit' => array('fit'), + 'pattern_outline' => array('pattern_outline'), + 'route_set' => array('route_set'), + ), + 'primary key' => array('name'), + ); + + $schema = Database::getConnection()->schema(); + + $schema->dropTable('router'); + + $schema->createTable('router', $tables['router']); +} + /** * @} End of "defgroup updates-7.x-to-8.x". * The next series of updates should start at 9000. diff --git a/core/modules/system/tests/modules/router_test/lib/Drupal/router_test/TestControllers.php b/core/modules/system/tests/modules/router_test/lib/Drupal/router_test/TestControllers.php new file mode 100644 index 0000000..fa92fd8 --- /dev/null +++ b/core/modules/system/tests/modules/router_test/lib/Drupal/router_test/TestControllers.php @@ -0,0 +1,33 @@ + '\Drupal\router_test\TestControllers::test1' + )); + $collection->add('router_test_1', $route); + + $route = new Route('router_test/test2', array( + '_content' => '\Drupal\router_test\TestControllers::test2' + )); + $collection->add('router_test_2', $route); + + $route = new Route('router_test/test3/{value}', array( + '_content' => '\Drupal\router_test\TestControllers::test3' + )); + $collection->add('router_test_3', $route); + + $route = new Route('router_test/test4/{value}', array( + '_content' => '\Drupal\router_test\TestControllers::test4', + 'value' => 'narf', + )); + $collection->add('router_test_4', $route); + + return $collection; +} diff --git a/core/modules/user/user.pages.inc b/core/modules/user/user.pages.inc index 9dd2cc9..34f24ab 100644 --- a/core/modules/user/user.pages.inc +++ b/core/modules/user/user.pages.inc @@ -420,9 +420,7 @@ function user_page() { global $user; if ($user->uid) { // @todo: Cleaner sub request handling. - $request = drupal_container()->get('request'); - $subrequest = Request::create('/user/' . $user->uid, 'GET', $request->query->all(), $request->cookies->all(), array(), $request->server->all()); - return drupal_container()->get('http_kernel')->handle($subrequest, HttpKernelInterface::SUB_REQUEST); + drupal_goto('user/' . $user->uid); } else { return drupal_get_form('user_login'); diff --git a/core/vendor/Routing b/core/vendor/Routing new file mode 160000 index 0000000..a05bcaa --- /dev/null +++ b/core/vendor/Routing @@ -0,0 +1 @@ +Subproject commit a05bcaaaa43025037a0667e158aed9b65a147e80