Skip to content

Commit 33b42b3

Browse files
authored
RedirectableUrlMatcher (#465)
1 parent e9c3dfb commit 33b42b3

File tree

10 files changed

+234
-0
lines changed

10 files changed

+234
-0
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
Changelog
22
=========
3+
2.6.0
4+
-----
5+
6+
* Implemented a new configuration option `redirectable_url_matcher` under `dynamic`.
7+
If set to `true`, the router will try to detect and redirect between URLs with and without trailing slashes. See https://symfony.com/doc/4.1/routing.html#routing-trailing-slash-redirection.
8+
39
2.5.1
410
-----
511

src/CmfRoutingBundle.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Doctrine\ORM\Mapping\Driver\XmlDriver as ORMXmlDriver;
1919
use Doctrine\ORM\Version as ORMVersion;
2020
use Doctrine\Persistence\Mapping\Driver\DefaultFileLocator;
21+
use Symfony\Cmf\Bundle\RoutingBundle\DependencyInjection\Compiler\RedirectableMatcherPass;
2122
use Symfony\Cmf\Bundle\RoutingBundle\DependencyInjection\Compiler\SetRouterPass;
2223
use Symfony\Cmf\Bundle\RoutingBundle\DependencyInjection\Compiler\TemplatingValidatorPass;
2324
use Symfony\Cmf\Bundle\RoutingBundle\DependencyInjection\Compiler\ValidationPass;
@@ -41,6 +42,7 @@ public function build(ContainerBuilder $container)
4142
$container->addCompilerPass(new SetRouterPass());
4243
$container->addCompilerPass(new ValidationPass());
4344
$container->addCompilerPass(new TemplatingValidatorPass());
45+
$container->addCompilerPass(new RedirectableMatcherPass());
4446

4547
$this->buildPhpcrCompilerPass($container);
4648
$this->buildOrmCompilerPass($container);

src/DependencyInjection/CmfRoutingExtension.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ private function setupDynamicRouter(array $config, ContainerBuilder $container,
8282
{
8383
$loader->load('routing-dynamic.xml');
8484

85+
$container->setParameter('cmf_routing.redirectable_url_matcher', $config['redirectable_url_matcher']);
86+
8587
// strip whitespace (XML support)
8688
foreach (['controllers_by_type', 'controllers_by_class', 'templates_by_class', 'route_filters_by_id'] as $option) {
8789
$config[$option] = array_map('trim', $config[$option]);
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony CMF package.
5+
*
6+
* (c) Symfony CMF
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Cmf\Bundle\RoutingBundle\DependencyInjection\Compiler;
13+
14+
use Symfony\Cmf\Bundle\RoutingBundle\Routing\RedirectableRequestMatcher;
15+
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
16+
use Symfony\Component\DependencyInjection\ContainerBuilder;
17+
use Symfony\Component\DependencyInjection\Definition;
18+
use Symfony\Component\DependencyInjection\Reference;
19+
20+
/**
21+
* If enabled, change the nested matcher implementation to the redirectable matcher.
22+
*/
23+
class RedirectableMatcherPass implements CompilerPassInterface
24+
{
25+
public function process(ContainerBuilder $container)
26+
{
27+
// only replace the nested matcher if config tells us to
28+
if (!$container->hasParameter('cmf_routing.redirectable_url_matcher') || false === $container->getParameter('cmf_routing.redirectable_url_matcher')) {
29+
return;
30+
}
31+
32+
$definition = new Definition(RedirectableRequestMatcher::class);
33+
34+
$container->setDefinition('cmf_routing.redirectable_request_matcher', $definition)
35+
->setDecoratedService('cmf_routing.nested_matcher', 'cmf_routing.nested_matcher.inner')
36+
->addArgument(new Reference('cmf_routing.nested_matcher.inner'))
37+
->addArgument(new Reference('router.request_context'));
38+
}
39+
}

src/DependencyInjection/Configuration.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ private function addDynamicSection(ArrayNodeDefinition $root)
151151
->end() // locales
152152
->integerNode('limit_candidates')->defaultValue(20)->end()
153153
->booleanNode('match_implicit_locale')->defaultValue(true)->end()
154+
->booleanNode('redirectable_url_matcher')->defaultValue(false)->end()
154155
->booleanNode('auto_locale_pattern')->defaultValue(false)->end()
155156
->scalarNode('url_generator')
156157
->defaultValue('cmf_routing.generator')
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony CMF package.
5+
*
6+
* (c) Symfony CMF
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Cmf\Bundle\RoutingBundle\Routing;
13+
14+
use Symfony\Component\HttpFoundation\Request;
15+
use Symfony\Component\Routing\Exception\ExceptionInterface;
16+
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
17+
use Symfony\Component\Routing\Matcher\RequestMatcherInterface;
18+
use Symfony\Component\Routing\RequestContext;
19+
20+
/**
21+
* Reproduce the behaviour of the Symfony router to redirect paths with a trailing slash if necessary.
22+
*
23+
* The logic is taken from Symfony\Component\Routing\Matcher\RedirectableUrlMatcher and Symfony\Bundle\FrameworkBundle\Routing\RedirectableCompiledUrlMatcher
24+
*
25+
* @see https://symfony.com/doc/4.1/routing.html#routing-trailing-slash-redirection
26+
*/
27+
class RedirectableRequestMatcher implements RequestMatcherInterface
28+
{
29+
private RequestMatcherInterface $decorated;
30+
31+
private RequestContext $requestContext;
32+
33+
public function __construct(RequestMatcherInterface $decorated, RequestContext $requestContext)
34+
{
35+
$this->decorated = $decorated;
36+
$this->requestContext = $requestContext;
37+
}
38+
39+
/**
40+
* If the matcher can not find a match,
41+
* it will try to add or remove a trailing slash to the path and match again.
42+
*/
43+
public function matchRequest(Request $request)
44+
{
45+
try {
46+
return $this->decorated->matchRequest($request);
47+
} catch (ResourceNotFoundException $e) {
48+
if (!\in_array($request->getMethod(), ['HEAD', 'GET'], true)) {
49+
throw $e;
50+
}
51+
52+
$pathinfo = $request->getPathInfo();
53+
54+
if ('/' === $trimmedPathinfo = rtrim($pathinfo, '/') ?: '/') {
55+
throw $e;
56+
}
57+
58+
$pathinfo = $trimmedPathinfo === $pathinfo ? $pathinfo.'/' : $trimmedPathinfo;
59+
60+
try {
61+
$parameters = $this->decorated->matchRequest($this->rebuildRequest($request, $pathinfo));
62+
} catch (ExceptionInterface $e2) {
63+
throw $e;
64+
}
65+
66+
return $this->redirect($pathinfo, $parameters['_route'] ?? null, $this->requestContext->getScheme()) + $parameters;
67+
}
68+
}
69+
70+
private function redirect(string $path, string $route, string $scheme = null): array
71+
{
72+
return [
73+
'_controller' => 'Symfony\\Bundle\\FrameworkBundle\\Controller\\RedirectController::urlRedirectAction',
74+
'path' => $path,
75+
'permanent' => true,
76+
'scheme' => $scheme,
77+
'httpPort' => $this->requestContext->getHttpPort(),
78+
'httpsPort' => $this->requestContext->getHttpsPort(),
79+
'_route' => $route,
80+
];
81+
}
82+
83+
/**
84+
* Return a duplicated version of $request with the new $newpath as request_uri.
85+
*/
86+
private function rebuildRequest(Request $request, string $newPath): Request
87+
{
88+
$server = $request->server->all();
89+
$server['REQUEST_URI'] = $newPath;
90+
91+
return $request->duplicate(null, null, null, null, null, $server);
92+
}
93+
}

tests/Fixtures/fixtures/config/config.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,6 @@
4040
'locales' => ['en', 'fr'],
4141
'auto_locale_pattern' => true,
4242
'match_implicit_locale' => true,
43+
'redirectable_url_matcher' => false,
4344
],
4445
]);

tests/Fixtures/fixtures/config/config.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,4 @@ cmf_routing:
2121
locales: [en, fr]
2222
auto_locale_pattern: true
2323
match_implicit_locale: true
24+
redirectable_url_matcher: false

tests/Unit/DependencyInjection/ConfigurationTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ public function testSupportsAllConfigFormats()
7676
'auto_locale_pattern' => true,
7777
'match_implicit_locale' => true,
7878
'url_generator' => 'cmf_routing.generator',
79+
'redirectable_url_matcher' => false,
7980
],
8081
];
8182

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony CMF package.
5+
*
6+
* (c) Symfony CMF
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Cmf\Bundle\RoutingBundle\Tests\Unit\Routing;
13+
14+
use PHPUnit\Framework\MockObject\MockObject;
15+
use PHPUnit\Framework\TestCase;
16+
use Symfony\Cmf\Bundle\RoutingBundle\Routing\RedirectableRequestMatcher;
17+
use Symfony\Component\HttpFoundation\Request;
18+
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
19+
use Symfony\Component\Routing\Matcher\RequestMatcherInterface;
20+
use Symfony\Component\Routing\RequestContext;
21+
22+
class RedirectableRequestMatcherTest extends TestCase
23+
{
24+
/**
25+
* @var RedirectableRequestMatcher
26+
*/
27+
private $redirectableRequestMatcher;
28+
29+
/**
30+
* @var RequestMatcherInterface|MockObject
31+
*/
32+
private $decoratedRequestMatcher;
33+
34+
/**
35+
* @var Request
36+
*/
37+
private $requestWithoutSlash;
38+
39+
/**
40+
* @var Request
41+
*/
42+
private $requestWithSlash;
43+
44+
/**
45+
* @var RequestContext|MockObject
46+
*/
47+
private $context;
48+
49+
public function setUp(): void
50+
{
51+
$this->requestWithoutSlash = Request::create('/foo');
52+
$this->requestWithSlash = Request::create('/foo/');
53+
$this->decoratedRequestMatcher = $this->createMock(RequestMatcherInterface::class);
54+
$this->context = $this->createMock(RequestContext::class);
55+
$this->redirectableRequestMatcher = new RedirectableRequestMatcher($this->decoratedRequestMatcher, $this->context);
56+
}
57+
58+
public function testMatchRequest()
59+
{
60+
$this->decoratedRequestMatcher
61+
->expects($this->once())
62+
->method('matchRequest')
63+
->with($this->requestWithoutSlash)
64+
->will($this->returnValue(['foo' => 'bar']));
65+
66+
$parameters = $this->redirectableRequestMatcher->matchRequest($this->requestWithoutSlash);
67+
$this->assertEquals(['foo' => 'bar'], $parameters);
68+
}
69+
70+
public function testMatchRequestWithSlash()
71+
{
72+
$this->decoratedRequestMatcher
73+
->method('matchRequest')
74+
->withConsecutive([$this->callback(function (Request $request) {
75+
return '/foo/' === $request->getPathInfo();
76+
})], [$this->callback(function (Request $request) {
77+
return '/foo' === $request->getPathInfo();
78+
})])
79+
->will($this->onConsecutiveCalls(
80+
$this->throwException(new ResourceNotFoundException()),
81+
$this->returnValue(['_route' => 'foobar'])
82+
));
83+
84+
$parameters = $this->redirectableRequestMatcher->matchRequest($this->requestWithSlash);
85+
$this->assertTrue('foobar' === $parameters['_route']);
86+
$this->assertTrue('/foo' === $parameters['path']);
87+
}
88+
}

0 commit comments

Comments
 (0)