summaryrefslogtreecommitdiffstatshomepage
path: root/core/lib/Drupal/Core/Routing/Router.php
blob: 91ff280e91172081e3822b8f2a70a1d10bf62614 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
<?php

namespace Drupal\Core\Routing;

use Drupal\Core\Path\CurrentPathStack;
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Exception\MethodNotAllowedException;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface as BaseUrlGeneratorInterface;
use Symfony\Component\Routing\Matcher\RequestMatcherInterface;
use Symfony\Component\Routing\RouteCollection;
use Symfony\Component\Routing\RouterInterface;

/**
 * Router implementation in Drupal.
 *
 * A router determines, for an incoming request, the active controller, which is
 * a callable that creates a response.
 *
 * It consists of several steps, of which each are explained in more details
 * below:
 * 1. Get a collection of routes which potentially match the current request.
 *    This is done by the route provider. See ::getInitialRouteCollection().
 * 2. Filter the collection down further more. For example this filters out
 *    routes applying to other formats: See ::applyRouteFilters()
 * 3. Find the best matching route out of the remaining ones, by applying a
 *    regex. See ::matchCollection().
 * 4. Enhance the list of route attributes, for example loading entity objects.
 *    See ::applyRouteEnhancers().
 */
class Router extends UrlMatcher implements RequestMatcherInterface, RouterInterface {

  /**
   * The route provider responsible for the first-pass match.
   *
   * @var \Drupal\Core\Routing\RouteProviderInterface
   */
  protected $routeProvider;

  /**
   * The list of available enhancers.
   *
   * @var \Drupal\Core\Routing\EnhancerInterface[]
   */
  protected $enhancers = [];

  /**
   * The list of available route filters.
   *
   * @var \Drupal\Core\Routing\FilterInterface[]
   */
  protected $filters = [];

  /**
   * The URL generator.
   *
   * @var \Symfony\Component\Routing\Generator\UrlGeneratorInterface
   */
  protected $urlGenerator;

  /**
   * Constructs a new Router.
   *
   * @param \Drupal\Core\Routing\RouteProviderInterface $route_provider
   *   The route provider.
   * @param \Drupal\Core\Path\CurrentPathStack $current_path
   *   The current path stack.
   * @param \Symfony\Component\Routing\Generator\UrlGeneratorInterface $url_generator
   *   The URL generator.
   */
  public function __construct(RouteProviderInterface $route_provider, CurrentPathStack $current_path, BaseUrlGeneratorInterface $url_generator) {
    parent::__construct($current_path);
    $this->routeProvider = $route_provider;
    $this->urlGenerator = $url_generator;
  }

  /**
   * Adds a route filter.
   *
   * @param \Drupal\Core\Routing\FilterInterface $route_filter
   *   The route filter.
   */
  public function addRouteFilter(FilterInterface $route_filter) {
    $this->filters[] = $route_filter;
  }

  /**
   * Adds a route enhancer.
   *
   * @param \Drupal\Core\Routing\EnhancerInterface $route_enhancer
   *   The route enhancer.
   */
  public function addRouteEnhancer(EnhancerInterface $route_enhancer) {
    $this->enhancers[] = $route_enhancer;
  }

  /**
   * {@inheritdoc}
   */
  public function match($pathinfo): array {
    try {
      $request = Request::create($pathinfo);
    }
    catch (BadRequestException $e) {
      throw new ResourceNotFoundException($e->getMessage(), $e->getCode(), $e);
    }

    return $this->matchRequest($request);
  }

  /**
   * {@inheritdoc}
   */
  public function matchRequest(Request $request): array {
    $collection = $this->getInitialRouteCollection($request);
    if ($collection->count() === 0) {
      throw new ResourceNotFoundException(sprintf('No routes found for "%s".', $this->currentPath->getPath()));
    }
    $collection = $this->applyRouteFilters($collection, $request);
    $collection = $this->applyFitOrder($collection);

    $ret = $this->matchCollection(rawurldecode($this->currentPath->getPath($request)), $collection);
    return $this->applyRouteEnhancers($ret, $request);
  }

  /**
   * {@inheritdoc}
   */
  protected function matchCollection($pathinfo, RouteCollection $routes): array {
    // Try a case-sensitive match.
    $match = $this->doMatchCollection($pathinfo, $routes, TRUE);
    // Try a case-insensitive match.
    if ($match === NULL && $routes->count() > 0) {
      $match = $this->doMatchCollection($pathinfo, $routes, FALSE);
    }
    if ($match === NULL) {
      throw 0 < count($this->allow)
        ? new MethodNotAllowedException(array_unique($this->allow))
        : new ResourceNotFoundException(sprintf('No routes found for "%s".', $this->currentPath->getPath()));
    }
    return $match;
  }

  /**
   * Tries to match a URL with a set of routes.
   *
   * This code is very similar to Symfony's UrlMatcher::matchCollection() but it
   * supports case-insensitive matching. The static prefix optimization is
   * removed as this duplicates work done by the query in
   * RouteProvider::getRoutesByPath().
   *
   * @param string $pathinfo
   *   The path info to be parsed.
   * @param \Symfony\Component\Routing\RouteCollection $routes
   *   The set of routes.
   * @param bool $case_sensitive
   *   Determines if the match should be case-sensitive of not.
   *
   * @return array|null
   *   An array of parameters. NULL when there is no match.
   *
   * @see \Symfony\Component\Routing\Matcher\UrlMatcher::matchCollection()
   * @see \Drupal\Core\Routing\RouteProvider::getRoutesByPath()
   */
  protected function doMatchCollection($pathinfo, RouteCollection $routes, $case_sensitive) {
    foreach ($routes as $name => $route) {
      $compiledRoute = $route->compile();

      // Set the regex to use UTF-8.
      $regex = $compiledRoute->getRegex() . 'u';
      if (!$case_sensitive) {
        $regex = $regex . 'i';
      }
      if (!preg_match($regex, $pathinfo, $matches)) {
        continue;
      }

      $hostMatches = [];
      if ($compiledRoute->getHostRegex() && !preg_match($compiledRoute->getHostRegex(), $this->context->getHost(), $hostMatches)) {
        $routes->remove($name);
        continue;
      }

      // Check HTTP method requirement.
      if ($requiredMethods = $route->getMethods()) {
        // HEAD and GET are equivalent as per RFC.
        if ('HEAD' === $method = $this->context->getMethod()) {
          $method = 'GET';
        }

        if (!in_array($method, $requiredMethods)) {
          $this->allow = array_merge($this->allow, $requiredMethods);
          $routes->remove($name);
          continue;
        }
      }

      $attributes = $this->getAttributes($route, $name, array_replace($matches, $hostMatches));

      $status = $this->handleRouteRequirements($pathinfo, $name, $route, $attributes);

      if (self::ROUTE_MATCH === $status[0]) {
        return $status[1];
      }

      if (self::REQUIREMENT_MISMATCH === $status[0]) {
        $routes->remove($name);
        continue;
      }

      return $attributes;
    }
  }

  /**
   * Returns a collection of potential matching routes for a request.
   *
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The current request.
   *
   * @return \Symfony\Component\Routing\RouteCollection
   *   The initial fetched route collection.
   */
  protected function getInitialRouteCollection(Request $request) {
    return $this->routeProvider->getRouteCollectionForRequest($request);
  }

  /**
   * Apply the route enhancers to the defaults, according to priorities.
   *
   * @param array $defaults
   *   The defaults coming from the final matched route.
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The request.
   *
   * @return array
   *   The request attributes after applying the enhancers. This might consist
   *   raw values from the URL but also upcasted values, like entity objects,
   *   from route enhancers.
   */
  protected function applyRouteEnhancers($defaults, Request $request) {
    foreach ($this->enhancers as $enhancer) {
      $defaults = $enhancer->enhance($defaults, $request);
    }

    return $defaults;
  }

  /**
   * Applies all route filters to a given route collection.
   *
   * This method reduces the sets of routes further down, for example by
   * checking the HTTP method.
   *
   * @param \Symfony\Component\Routing\RouteCollection $collection
   *   The route collection.
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The request.
   *
   * @return \Symfony\Component\Routing\RouteCollection
   *   The filtered/sorted route collection.
   */
  protected function applyRouteFilters(RouteCollection $collection, Request $request) {
    // Route filters are expected to throw an exception themselves if they
    // end up filtering the list down to 0.
    foreach ($this->filters as $filter) {
      $collection = $filter->filter($collection, $request);
    }

    return $collection;
  }

  /**
   * Reapplies the fit order to a RouteCollection object.
   *
   * Route filters can reorder route collections. For example, routes with an
   * explicit _format requirement will be preferred. This can result in a less
   * fit route being used. For example, as a result of filtering /user/% comes
   * before /user/login. In order to not break this fundamental property of
   * routes, we need to reapply the fit order. We also need to ensure that order
   * within each group of the same fit is preserved.
   *
   * @param \Symfony\Component\Routing\RouteCollection $collection
   *   The route collection.
   *
   * @return \Symfony\Component\Routing\RouteCollection
   *   The reordered route collection.
   */
  protected function applyFitOrder(RouteCollection $collection) {
    $buckets = [];
    // Sort all the routes by fit descending.
    foreach ($collection->all() as $name => $route) {
      $fit = $route->compile()->getFit();
      $buckets += [$fit => []];
      $buckets[$fit][] = [$name, $route];
    }
    krsort($buckets);

    $flattened = array_reduce($buckets, 'array_merge', []);

    // Add them back onto a new route collection.
    $collection = new RouteCollection();
    foreach ($flattened as $pair) {
      $name = $pair[0];
      $route = $pair[1];
      $collection->add($name, $route);
    }
    return $collection;
  }

  /**
   * {@inheritdoc}
   */
  public function getRouteCollection(): RouteCollection {
    return new LazyRouteCollection($this->routeProvider);
  }

  /**
   * This method is intentionally not implemented.
   *
   * Use Drupal\Core\Url instead.
   *
   * @see https://www.drupal.org/node/2820197
   *
   * @throws \BadMethodCallException
   */
  public function generate($name, $parameters = [], $referenceType = self::ABSOLUTE_PATH): string {
    throw new \BadMethodCallException(__METHOD__ . '() is not supported. Use the \Drupal\Core\Url object instead. See https://www.drupal.org/node/2820197');
  }

}