Skip to content

Commit f24dea6

Browse files
committed
Add LiveDownloadResponse + checks in Subscriber
1 parent 2f34d71 commit f24dea6

File tree

8 files changed

+200
-33
lines changed

8 files changed

+200
-33
lines changed

src/LiveComponent/src/EventListener/LiveComponentSubscriber.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use Psr\Container\ContainerInterface;
1515
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
16+
use Symfony\Component\HttpFoundation\BinaryFileResponse;
1617
use Symfony\Component\HttpFoundation\Exception\JsonException;
1718
use Symfony\Component\HttpFoundation\Request;
1819
use Symfony\Component\HttpFoundation\Response;
@@ -43,7 +44,9 @@
4344
class LiveComponentSubscriber implements EventSubscriberInterface, ServiceSubscriberInterface
4445
{
4546
private const HTML_CONTENT_TYPE = 'application/vnd.live-component+html';
47+
4648
private const REDIRECT_HEADER = 'X-Live-Redirect';
49+
private const DOWNLOAD_HEADER = 'X-Live-Download';
4750

4851
public function __construct(
4952
private ContainerInterface $container,
@@ -254,6 +257,13 @@ public function onKernelView(ViewEvent $event): void
254257

255258
return;
256259
}
260+
261+
if ($event->getControllerResult() instanceof BinaryFileResponse) {
262+
if (!$event->getControllerResult()->headers->has(self::DOWNLOAD_HEADER)) {
263+
264+
}
265+
$event->setResponse(new Response());
266+
}
257267

258268
$event->setResponse($this->createResponse($request->attributes->get('_mounted_component')));
259269
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
namespace Symfony\UX\LiveComponent;
4+
5+
use SplFileInfo;
6+
use SplTempFileObject;
7+
use Symfony\Component\HttpFoundation\BinaryFileResponse;
8+
use Symfony\Component\HttpFoundation\HeaderUtils;
9+
10+
/**
11+
* @author Simon André <smn.andre@gmail.com>
12+
*/
13+
final class LiveDownloadResponse extends BinaryFileResponse
14+
{
15+
public const HEADER_LIVE_DOWNLOAD = 'X-Live-Download';
16+
17+
public function __construct(string|SplFileInfo $file, ?string $filename = null)
18+
{
19+
if (\is_string($file)) {
20+
$file = new SplFileInfo($file);
21+
}
22+
23+
if ((!$file instanceof SplFileInfo)) {
24+
throw new \InvalidArgumentException(sprintf('The file "%s" does not exist.', $file));
25+
}
26+
27+
if ($file instanceof SplTempFileObject) {
28+
$file->rewind();
29+
}
30+
31+
parent::__construct($file, 200, [
32+
self::HEADER_LIVE_DOWNLOAD => 1,
33+
'Content-Disposition' => HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $filename ?? basename($file)),
34+
'Content-Type' => 'application/octet-stream',
35+
'Content-Length' => $file instanceof SplTempFileObject ? 0 : $file->getSize(),
36+
], false, HeaderUtils::DISPOSITION_ATTACHMENT);
37+
}
38+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
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\Fixtures\Component;
13+
14+
use Symfony\Component\HttpFoundation\BinaryFileResponse;
15+
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
16+
use Symfony\UX\LiveComponent\Attribute\LiveAction;
17+
use Symfony\UX\LiveComponent\Attribute\LiveArg;
18+
use Symfony\UX\LiveComponent\DefaultActionTrait;
19+
use Symfony\UX\LiveComponent\LiveDownloadResponse;
20+
21+
/**
22+
* @author Simon André <smn.andre@gmail.com>
23+
*/
24+
#[AsLiveComponent('download_file', template: 'components/download_file.html.twig')]
25+
class DownloadFileComponent
26+
{
27+
use DefaultActionTrait;
28+
29+
private const FILE_DIRECTORY = __DIR__.'/../files/';
30+
31+
#[LiveAction]
32+
public function download(): BinaryFileResponse
33+
{
34+
$file = new \SplFileInfo(self::FILE_DIRECTORY.'/foo.json');
35+
36+
return new LiveDownloadResponse($file);
37+
}
38+
39+
#[LiveAction]
40+
public function generate(): BinaryFileResponse
41+
{
42+
$file = new \SplTempFileObject();
43+
$file->fwrite(file_get_contents(self::FILE_DIRECTORY.'/foo.json'));
44+
45+
return new LiveDownloadResponse($file, 'foo.json');
46+
}
47+
48+
#[LiveAction]
49+
public function heavyFile(#[LiveArg] int $size): BinaryFileResponse
50+
{
51+
$file = new \SplFileInfo(self::FILE_DIRECTORY.'heavy.txt');
52+
53+
$response = new BinaryFileResponse($file);
54+
$response->headers->set('Content-Length', 10000000); // 10MB
55+
}
56+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<title>Foo</title>
5+
</head>
6+
<body>
7+
<h1>Bar</h1>
8+
</body>
9+
</html>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"foo": "bar"
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Foo
2+
3+
## Bar
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<div {{ attributes }}>
2+
3+
</div>

src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php

Lines changed: 78 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,11 @@
1212
namespace Symfony\UX\LiveComponent\Tests\Functional\EventListener;
1313

1414
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
15-
use Symfony\Component\DomCrawler\Crawler;
16-
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
1715
use Symfony\Component\Security\Core\User\InMemoryUser;
1816
use Symfony\Component\Security\Http\Attribute\IsGranted;
1917
use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\Entity1;
2018
use Symfony\UX\LiveComponent\Tests\LiveComponentTestHelper;
19+
use Zenstruck\Browser;
2120
use Zenstruck\Browser\Test\HasBrowser;
2221
use Zenstruck\Foundry\Test\Factories;
2322
use Zenstruck\Foundry\Test\ResetDatabase;
@@ -70,8 +69,7 @@ public function testCanRenderComponentAsHtml(): void
7069
->assertContains('Prop1: '.$entity->id)
7170
->assertContains('Prop2: 2021-03-05 9:23')
7271
->assertContains('Prop3: value3')
73-
->assertContains('Prop4: (none)')
74-
;
72+
->assertContains('Prop4: (none)');
7573
}
7674

7775
public function testCanRenderComponentAsHtmlWithAlternateRoute(): void
@@ -89,8 +87,7 @@ public function testCanRenderComponentAsHtmlWithAlternateRoute(): void
8987
])
9088
->assertSuccessful()
9189
->assertOn('/alt/alternate_route', parts: ['path'])
92-
->assertContains('From alternate route. (count: 0)')
93-
;
90+
->assertContains('From alternate route. (count: 0)');
9491
}
9592

9693
public function testCanExecuteComponentActionNormalRoute(): void
@@ -127,8 +124,7 @@ public function testCanExecuteComponentActionNormalRoute(): void
127124
->assertSuccessful()
128125
->assertHeaderContains('Content-Type', 'html')
129126
->assertContains('Count: 2')
130-
->assertSee('Embedded content with access to context, like count=2')
131-
;
127+
->assertSee('Embedded content with access to context, like count=2');
132128
}
133129

134130
public function testCanExecuteComponentActionWithAlternateRoute(): void
@@ -151,24 +147,21 @@ public function testCanExecuteComponentActionWithAlternateRoute(): void
151147
])
152148
->assertSuccessful()
153149
->assertOn('/alt/alternate_route/increase')
154-
->assertContains('count: 1')
155-
;
150+
->assertContains('count: 1');
156151
}
157152

158153
public function testCannotExecuteComponentActionForGetRequest(): void
159154
{
160155
$this->browser()
161156
->get('/_components/component2/increase')
162-
->assertStatus(405)
163-
;
157+
->assertStatus(405);
164158
}
165159

166160
public function testCannotExecuteComponentDefaultActionForGetRequestWhenMethodIsPost(): void
167161
{
168162
$this->browser()
169163
->get('/_components/with_method_post/__invoke')
170-
->assertStatus(405)
171-
;
164+
->assertStatus(405);
172165
}
173166

174167
public function testPreReRenderHookOnlyExecutedDuringAjax(): void
@@ -187,8 +180,7 @@ public function testPreReRenderHookOnlyExecutedDuringAjax(): void
187180
],
188181
])
189182
->assertSuccessful()
190-
->assertSee('PreReRenderCalled: Yes')
191-
;
183+
->assertSee('PreReRenderCalled: Yes');
192184
}
193185

194186
public function testItAddsEmbeddedTemplateContextToEmbeddedComponents(): void
@@ -224,8 +216,7 @@ public function testItAddsEmbeddedTemplateContextToEmbeddedComponents(): void
224216
])
225217
->assertSuccessful()
226218
->assertSee('PreReRenderCalled: Yes')
227-
->assertSee('Embedded content with access to context, like count=1')
228-
;
219+
->assertSee('Embedded content with access to context, like count=1');
229220
}
230221

231222
public function testItWorksWithNamespacedTemplateNamesForEmbeddedComponents(): void
@@ -237,8 +228,7 @@ public function testItWorksWithNamespacedTemplateNamesForEmbeddedComponents(): v
237228
$this->browser()
238229
->visit('/render-namespaced-template/render_embedded_with_blocks')
239230
->assertSuccessful()
240-
->assertElementAttributeContains('.component2', 'data-live-props-value', '"data-host-template":"'.$obscuredName.'"')
241-
;
231+
->assertElementAttributeContains('.component2', 'data-live-props-value', '"data-host-template":"'.$obscuredName.'"');
242232
}
243233

244234
public function testItUseBlocksFromEmbeddedContextUsingMultipleComponents(): void
@@ -269,8 +259,7 @@ public function testItUseBlocksFromEmbeddedContextUsingMultipleComponents(): voi
269259
])
270260
->assertSuccessful()
271261
->assertHeaderContains('Content-Type', 'html')
272-
->assertSee('Overridden content from component 2 on same line - count: 2')
273-
;
262+
->assertSee('Overridden content from component 2 on same line - count: 2');
274263
}
275264

276265
public function testItUseBlocksFromEmbeddedContextUsingMultipleComponentsWithNamespacedTemplate(): void
@@ -301,8 +290,7 @@ public function testItUseBlocksFromEmbeddedContextUsingMultipleComponentsWithNam
301290
])
302291
->assertSuccessful()
303292
->assertHeaderContains('Content-Type', 'html')
304-
->assertSee('Overridden content from component 2 on same line - count: 2')
305-
;
293+
->assertSee('Overridden content from component 2 on same line - count: 2');
306294
}
307295

308296
public function testCanRedirectFromComponentAction(): void
@@ -336,10 +324,71 @@ public function testCanRedirectFromComponentAction(): void
336324
->assertStatus(204)
337325
->assertHeaderEquals('Location', '/')
338326
->assertHeaderContains('X-Live-Redirect', '1')
339-
->assertHeaderEquals('X-Custom-Header', '1')
327+
->assertHeaderEquals('X-Custom-Header', '1');
328+
}
329+
330+
public function testCanDownloadFileFromComponentAction(): void
331+
{
332+
$dehydrated = $this->dehydrateComponent($this->mountComponent('download_file'));
333+
334+
$this->browser()
335+
->throwExceptions()
336+
->post('/_components/download_file', [
337+
'body' => [
338+
'data' => json_encode([
339+
'props' => $dehydrated->getProps(),
340+
]),
341+
],
342+
])
343+
344+
->interceptRedirects()
345+
->post('/_components/download_file/download', [
346+
'headers' => [
347+
'Accept' => 'application/vnd.live-component+html',
348+
],
349+
'body' => ['data' => json_encode(['props' => $dehydrated->getProps()])],
350+
])
351+
->assertStatus(200)
352+
->assertHeaderContains('X-Live-Download', '1')
353+
->assertHeaderContains('Content-Type', 'application/octet-stream')
354+
->assertHeaderContains('Content-Disposition', 'attachment')
355+
->assertHeaderEquals('Content-Length', '21')
340356
;
341357
}
342358

359+
public function testCanDownloadGeneratedFileFromComponentAction(): void
360+
{
361+
$dehydrated = $this->dehydrateComponent($this->mountComponent('download_file'));
362+
363+
$this->browser()
364+
->throwExceptions()
365+
->post('/_components/download_file', [
366+
'body' => [
367+
'data' => json_encode([
368+
'props' => $dehydrated->getProps(),
369+
]),
370+
],
371+
])
372+
->interceptRedirects()
373+
->assertSuccessful()
374+
->post('/_components/download_file/generate', [
375+
'body' => [
376+
'data' => json_encode([
377+
'props' => $dehydrated->getProps(),
378+
]),
379+
],
380+
])
381+
->assertStatus(200)
382+
->assertHeaderContains('X-Live-Download', '1')
383+
->assertHeaderContains('Content-Type', 'application/octet-stream')
384+
->assertHeaderContains('Content-Disposition', 'attachment')
385+
->assertHeaderEquals('Content-Length', '21')
386+
->use(function(Browser $browser) {
387+
self::assertJson($browser->content());
388+
self::assertSame(['foo' => 'bar'], \json_decode($browser->content(), true));
389+
});
390+
}
391+
343392
public function testInjectsLiveArgs(): void
344393
{
345394
$dehydrated = $this->dehydrateComponent($this->mountComponent('component6'));
@@ -371,8 +420,7 @@ public function testInjectsLiveArgs(): void
371420
->assertHeaderContains('Content-Type', 'html')
372421
->assertContains('Arg1: hello')
373422
->assertContains('Arg2: 666')
374-
->assertContains('Arg3: 33.3')
375-
;
423+
->assertContains('Arg3: 33.3');
376424
}
377425

378426
public function testWithNullableEntity(): void
@@ -389,8 +437,7 @@ public function testWithNullableEntity(): void
389437
],
390438
])
391439
->assertSuccessful()
392-
->assertContains('Prop1: default')
393-
;
440+
->assertContains('Prop1: default');
394441
}
395442

396443
public function testCanHaveControllerAttributes(): void
@@ -407,8 +454,7 @@ public function testCanHaveControllerAttributes(): void
407454
->actingAs(new InMemoryUser('kevin', 'pass', ['ROLE_USER']))
408455
->assertAuthenticated('kevin')
409456
->post('/_components/with_security?props='.urlencode(json_encode($dehydrated->getProps())))
410-
->assertSuccessful()
411-
;
457+
->assertSuccessful();
412458
}
413459

414460
public function testCanInjectSecurityUserIntoAction(): void
@@ -436,7 +482,6 @@ public function testCanInjectSecurityUserIntoAction(): void
436482
],
437483
])
438484
->assertSuccessful()
439-
->assertSee('username: kevin')
440-
;
485+
->assertSee('username: kevin');
441486
}
442487
}

0 commit comments

Comments
 (0)