Skip to content

Commit 3a893ab

Browse files
committed
UrlFactory
1 parent 3a09999 commit 3a893ab

File tree

4 files changed

+292
-55
lines changed

4 files changed

+292
-55
lines changed

src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
use Symfony\UX\LiveComponent\Util\LiveControllerAttributesCreator;
5454
use Symfony\UX\LiveComponent\Util\RequestPropsExtractor;
5555
use Symfony\UX\LiveComponent\Util\TwigAttributeHelperFactory;
56+
use Symfony\UX\LiveComponent\Util\UrlFactory;
5657
use Symfony\UX\TwigComponent\ComponentFactory;
5758
use Symfony\UX\TwigComponent\ComponentRenderer;
5859

src/LiveComponent/src/EventListener/LiveUrlSubscriber.php

Lines changed: 20 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -15,40 +15,47 @@
1515
use Symfony\Component\HttpFoundation\Request;
1616
use Symfony\Component\HttpKernel\Event\ResponseEvent;
1717
use Symfony\Component\HttpKernel\KernelEvents;
18-
use Symfony\Component\Routing\RouterInterface;
1918
use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory;
19+
use Symfony\UX\LiveComponent\Util\UrlFactory;
2020

2121
class LiveUrlSubscriber implements EventSubscriberInterface
2222
{
2323
private const URL_HEADER = 'X-Live-Url';
2424

2525
public function __construct(
26-
private readonly RouterInterface $router,
27-
private readonly LiveComponentMetadataFactory $metadataFactory,
26+
private LiveComponentMetadataFactory $metadataFactory,
27+
private UrlFactory $urlFactory,
2828
) {
2929
}
3030

3131
public function onKernelResponse(ResponseEvent $event): void
3232
{
33-
if (!$this->isLiveComponentRequest($request = $event->getRequest())) {
33+
$request = $event->getRequest();
34+
if (!$request->attributes->has('_live_component')) {
3435
return;
3536
}
3637
if (!$event->isMainRequest()) {
3738
return;
3839
}
3940

41+
$newUrl = null;
4042
if ($previousLocation = $request->headers->get(self::URL_HEADER)) {
41-
$newUrl = $this->computeNewUrl(
42-
$previousLocation,
43-
$this->getLivePropsToMap($request)
44-
);
45-
if ($newUrl) {
46-
$event->getResponse()->headers->set(
47-
self::URL_HEADER,
48-
$newUrl
43+
$liveProps = $this->getLivePropsToMap($request);
44+
if (!empty($liveProps)) {
45+
$newUrl = $this->urlFactory->createFromPreviousAndProps(
46+
$previousLocation,
47+
$liveProps['path'],
48+
$liveProps['query']
4949
);
5050
}
5151
}
52+
53+
if ($newUrl) {
54+
$event->getResponse()->headers->set(
55+
self::URL_HEADER,
56+
$newUrl
57+
);
58+
}
5259
}
5360

5461
public static function getSubscribedEvents(): array
@@ -71,9 +78,7 @@ private function getLivePropsToMap(Request $request): array
7178
'path' => [],
7279
'query' => [],
7380
];
74-
foreach ($metadata->getAllLivePropsMetadata($component) as $liveProp) {
75-
$name = $liveProp->getName();
76-
$urlMapping = $liveProp->urlMapping();
81+
foreach ($metadata->getAllUrlMappings() as $name => $urlMapping) {
7782
if (isset($values[$name]) && $urlMapping) {
7883
$urlLiveProps[$urlMapping->mapPath ? 'path' : 'query'][$urlMapping->as ?? $name] =
7984
$values[$name];
@@ -82,44 +87,4 @@ private function getLivePropsToMap(Request $request): array
8287

8388
return $urlLiveProps;
8489
}
85-
86-
private function computeNewUrl(string $previousUrl, array $livePropsToMap): string
87-
{
88-
$parsed = parse_url($previousUrl);
89-
90-
$url = $parsed['path'] ?? '';
91-
if (isset($parsed['query'])) {
92-
$url .= '?'.$parsed['query'];
93-
}
94-
parse_str($parsed['query'] ?? '', $previousQueryParams);
95-
96-
$newUrl = $this->router->generate(
97-
$this->router->match($url)['_route'],
98-
array_merge($previousQueryParams, $livePropsToMap['path'])
99-
);
100-
parse_str(parse_url($newUrl)['query'] ?? '', $queryParams);
101-
$queryString = http_build_query(array_merge($queryParams, $livePropsToMap['query']));
102-
103-
return preg_replace('/[?#].*/', '', $newUrl).
104-
('' !== $queryString ? '?' : '').
105-
$queryString;
106-
}
107-
108-
/**
109-
* copied from LiveComponentSubscriber.
110-
*/
111-
private function isLiveComponentRequest(Request $request): bool
112-
{
113-
if (!$request->attributes->has('_live_component')) {
114-
return false;
115-
}
116-
117-
// if ($this->testMode) {
118-
// return true;
119-
// }
120-
121-
// Except when testing, require the correct content-type in the Accept header.
122-
// This also acts as a CSRF protection since this can only be set in accordance with same-origin/CORS policies.
123-
return \in_array('application/vnd.live-component+html', $request->getAcceptableContentTypes(), true);
124-
}
12590
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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\UX\LiveComponent\Util;
13+
14+
use Symfony\Component\Routing\Exception\MissingMandatoryParametersException;
15+
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
16+
use Symfony\Component\Routing\RouterInterface;
17+
18+
/**
19+
* @internal
20+
*/
21+
class UrlFactory
22+
{
23+
public function __construct(
24+
private RouterInterface $router,
25+
) {
26+
}
27+
28+
public function createFromPreviousAndProps(
29+
string $previousUrl,
30+
array $pathMappedProps,
31+
array $queryMappedProps,
32+
): ?string {
33+
$parsed = parse_url($previousUrl);
34+
if (false === $parsed) {
35+
return null;
36+
}
37+
38+
// Make sure to handle only path and query
39+
$previousUrl = $parsed['path'] ?? '';
40+
if (isset($parsed['query'])) {
41+
$previousUrl .= '?'.$parsed['query'];
42+
}
43+
44+
try {
45+
$newUrl = $this->createPath($previousUrl, $pathMappedProps);
46+
} catch (ResourceNotFoundException|MissingMandatoryParametersException) {
47+
return null;
48+
}
49+
50+
return $this->replaceQueryString(
51+
$newUrl,
52+
array_merge(
53+
$this->getPreviousQueryParameters($parsed['query'] ?? ''),
54+
$this->getRemnantProps($newUrl),
55+
$queryMappedProps,
56+
)
57+
);
58+
}
59+
60+
private function createPath(string $previousUrl, array $props): string
61+
{
62+
return $this->router->generate(
63+
$this->router->match($previousUrl)['_route'] ?? '',
64+
$props
65+
);
66+
}
67+
68+
private function replaceQueryString($url, array $props): string
69+
{
70+
$queryString = http_build_query($props);
71+
72+
return preg_replace('/[?#].*/', '', $url).
73+
('' !== $queryString ? '?' : '').
74+
$queryString;
75+
}
76+
77+
// Keep the query parameters of the previous request
78+
private function getPreviousQueryParameters(string $query): array
79+
{
80+
parse_str($query, $previousQueryParams);
81+
82+
return $previousQueryParams;
83+
}
84+
85+
// Symfony router will set props in query if they do not match route parameter
86+
private function getRemnantProps(string $newUrl): array
87+
{
88+
parse_str(parse_url($newUrl)['query'] ?? '', $remnantQueryParams);
89+
90+
return $remnantQueryParams;
91+
}
92+
}
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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\UX\LiveComponent\Tests\Unit\Util;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Routing\Exception\MissingMandatoryParametersException;
16+
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
17+
use Symfony\Component\Routing\RouterInterface;
18+
use Symfony\UX\LiveComponent\Util\UrlFactory;
19+
20+
class UrlFactoryTest extends TestCase
21+
{
22+
public function getData(): \Generator
23+
{
24+
yield 'keep_default_url' => [];
25+
26+
yield 'keep_relative_url' => [
27+
'input' => ['previousUrl' => '/foo/bar'],
28+
'expectedUrl' => '/foo/bar',
29+
];
30+
31+
yield 'keep_absolute_url' => [
32+
'input' => ['previousUrl' => 'https://symfony.com/foo/bar'],
33+
'expectedUrl' => '/foo/bar',
34+
'routerStubData' => [
35+
'previousUrl' => '/foo/bar',
36+
'newUrl' => '/foo/bar',
37+
],
38+
];
39+
40+
yield 'keep_url_with_query_parameters' => [
41+
'input' => ['previousUrl' => 'https://symfony.com/foo/bar?prop1=val1&prop2=val2'],
42+
'/foo/bar?prop1=val1&prop2=val2',
43+
'routerStubData' => [
44+
'previousUrl' => '/foo/bar?prop1=val1&prop2=val2',
45+
'newUrl' => '/foo/bar?prop1=val1&prop2=val2',
46+
],
47+
];
48+
49+
yield 'add_query_parameters' => [
50+
'input' => [
51+
'previousUrl' => '/foo/bar',
52+
'queryMappedProps' => ['prop1' => 'val1', 'prop2' => 'val2'],
53+
],
54+
'expectedUrl' => '/foo/bar?prop1=val1&prop2=val2',
55+
];
56+
57+
yield 'override_previous_matching_query_parameters' => [
58+
'input' => [
59+
'previousUrl' => '/foo/bar?prop1=oldValue&prop3=oldValue',
60+
'queryMappedProps' => ['prop1' => 'val1', 'prop2' => 'val2'],
61+
],
62+
'expectedUrl' => '/foo/bar?prop1=val1&prop3=oldValue&prop2=val2',
63+
];
64+
65+
yield 'add_path_parameters' => [
66+
'input' => [
67+
'previousUrl' => '/foo/bar',
68+
'pathMappedProps' => ['value' => 'baz'],
69+
],
70+
'expectedUrl' => '/foo/baz',
71+
'routerStubData' => [
72+
'previousUrl' => '/foo/bar',
73+
'newUrl' => '/foo/baz',
74+
'props' => ['value' => 'baz'],
75+
],
76+
];
77+
78+
yield 'add_both_parameters' => [
79+
'input' => [
80+
'previousUrl' => '/foo/bar',
81+
'pathMappedProps' => ['value' => 'baz'],
82+
'queryMappedProps' => ['filter' => 'all'],
83+
],
84+
'expectedUrl' => '/foo/baz?filter=all',
85+
'routerStubData' => [
86+
'previousUrl' => '/foo/bar',
87+
'newUrl' => '/foo/baz',
88+
'props' => ['value' => 'baz'],
89+
],
90+
];
91+
92+
yield 'handle_path_parameter_not_recognized' => [
93+
'input' => [
94+
'previousUrl' => '/foo/bar',
95+
'pathMappedProps' => ['value' => 'baz'],
96+
],
97+
'expectedUrl' => '/foo/bar?value=baz',
98+
'routerStubData' => [
99+
'previousUrl' => '/foo/bar',
100+
'newUrl' => '/foo/bar?value=baz',
101+
'props' => ['value' => 'baz'],
102+
],
103+
];
104+
}
105+
106+
/**
107+
* @dataProvider getData
108+
*/
109+
public function testCreate(
110+
array $input = [],
111+
string $expectedUrl = '',
112+
array $routerStubData = [],
113+
): void {
114+
$previousUrl = $input['previousUrl'] ?? '';
115+
$router = $this->createRouterStub(
116+
$routerStubData['previousUrl'] ?? $previousUrl,
117+
$routerStubData['newUrl'] ?? $previousUrl,
118+
$routerStubData['props'] ?? [],
119+
);
120+
$factory = new UrlFactory($router);
121+
$newUrl = $factory->createFromPreviousAndProps(
122+
$previousUrl,
123+
$input['pathMappedProps'] ?? [],
124+
$input['queryMappedProps'] ?? []
125+
);
126+
127+
$this->assertEquals($expectedUrl, $newUrl);
128+
}
129+
130+
public function testResourceNotFoundException(): void
131+
{
132+
$previousUrl = '/foo/bar';
133+
$router = $this->createMock(RouterInterface::class);
134+
$router->expects(self::once())
135+
->method('match')
136+
->with($previousUrl)
137+
->willThrowException(new ResourceNotFoundException());
138+
$factory = new UrlFactory($router);
139+
140+
$this->assertNull($factory->createFromPreviousAndProps($previousUrl, [], []));
141+
}
142+
143+
public function testMissingMandatoryParametersException(): void
144+
{
145+
$previousUrl = '/foo/bar';
146+
$matchedRoute = 'foo_bar';
147+
$router = $this->createMock(RouterInterface::class);
148+
$router->expects(self::once())
149+
->method('match')
150+
->with($previousUrl)
151+
->willReturn(['_route' => $matchedRoute]);
152+
$router->expects(self::once())
153+
->method('generate')
154+
->with($matchedRoute, [])
155+
->willThrowException(new MissingMandatoryParametersException($matchedRoute));
156+
$factory = new UrlFactory($router);
157+
158+
$this->assertNull($factory->createFromPreviousAndProps($previousUrl, [], []));
159+
}
160+
161+
private function createRouterStub(
162+
string $previousUrl,
163+
string $newUrl,
164+
array $props = [],
165+
): RouterInterface {
166+
$matchedRoute = 'default';
167+
$router = $this->createMock(RouterInterface::class);
168+
$router->expects(self::once())
169+
->method('match')
170+
->with($previousUrl)
171+
->willReturn(['_route' => $matchedRoute]);
172+
$router->expects(self::once())
173+
->method('generate')
174+
->with($matchedRoute, $props)
175+
->willReturn($newUrl);
176+
177+
return $router;
178+
}
179+
}

0 commit comments

Comments
 (0)