Skip to content

Commit 1be4006

Browse files
authored
Feat: attachement endpoint (#166)
New Features Added an attachment download endpoint that streams files with correct headers (Content-Type, Content-Disposition, Content-Length). Bug Fixes Consolidated and standardized JSON error responses with proper HTTP statuses (404 for missing attachments/subscribers/files, 403 for access denied, others mapped appropriately). Tests Added integration tests and fixtures covering successful streaming downloads, header/body validation, and not-found/error scenarios.
1 parent 8b1e424 commit 1be4006

4 files changed

Lines changed: 291 additions & 49 deletions

File tree

src/Common/EventListener/ExceptionListener.php

Lines changed: 39 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66

77
use Exception;
88
use PhpList\Core\Domain\Identity\Exception\AdminAttributeCreationException;
9+
use PhpList\Core\Domain\Messaging\Exception\AttachmentFileNotFoundException;
910
use PhpList\Core\Domain\Messaging\Exception\MessageNotReceivedException;
11+
use PhpList\Core\Domain\Messaging\Exception\SubscriberNotFoundException;
1012
use PhpList\Core\Domain\Subscription\Exception\AttributeDefinitionCreationException;
1113
use PhpList\Core\Domain\Subscription\Exception\SubscriptionCreationException;
1214
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -18,61 +20,49 @@
1820

1921
class ExceptionListener
2022
{
21-
/**
22-
* @SuppressWarnings(PHPMD.CyclomaticComplexity)
23-
*/
23+
private const EXCEPTION_STATUS_MAP = [
24+
SubscriptionCreationException::class => null,
25+
AttributeDefinitionCreationException::class => null,
26+
AdminAttributeCreationException::class => null,
27+
ValidatorException::class => 400,
28+
AccessDeniedException::class => 403,
29+
AccessDeniedHttpException::class => 403,
30+
AttachmentFileNotFoundException::class => 404,
31+
SubscriberNotFoundException::class => 404,
32+
MessageNotReceivedException::class => 422,
33+
];
34+
2435
public function onKernelException(ExceptionEvent $event): void
2536
{
2637
$exception = $event->getThrowable();
2738

28-
if ($exception instanceof AccessDeniedHttpException) {
29-
$response = new JsonResponse([
30-
'message' => $exception->getMessage(),
31-
], 403);
32-
33-
$event->setResponse($response);
34-
} elseif ($exception instanceof HttpExceptionInterface) {
35-
$response = new JsonResponse([
36-
'message' => $exception->getMessage(),
37-
], $exception->getStatusCode());
39+
foreach (self::EXCEPTION_STATUS_MAP as $class => $statusCode) {
40+
if ($exception instanceof $class) {
41+
$status = $statusCode ?? $exception->getStatusCode();
42+
$event->setResponse(
43+
new JsonResponse([
44+
'message' => $exception->getMessage()
45+
], $status)
46+
);
47+
return;
48+
}
49+
}
3850

39-
$event->setResponse($response);
40-
} elseif ($exception instanceof SubscriptionCreationException) {
41-
$response = new JsonResponse([
42-
'message' => $exception->getMessage(),
43-
], $exception->getStatusCode());
44-
$event->setResponse($response);
45-
} elseif ($exception instanceof AdminAttributeCreationException) {
46-
$response = new JsonResponse([
47-
'message' => $exception->getMessage(),
48-
], $exception->getStatusCode());
49-
$event->setResponse($response);
50-
} elseif ($exception instanceof AttributeDefinitionCreationException) {
51-
$response = new JsonResponse([
52-
'message' => $exception->getMessage(),
53-
], $exception->getStatusCode());
54-
$event->setResponse($response);
55-
} elseif ($exception instanceof ValidatorException) {
56-
$response = new JsonResponse([
57-
'message' => $exception->getMessage(),
58-
], 400);
59-
$event->setResponse($response);
60-
} elseif ($exception instanceof AccessDeniedException) {
61-
$response = new JsonResponse([
62-
'message' => $exception->getMessage(),
63-
], 403);
64-
$event->setResponse($response);
65-
} elseif ($exception instanceof MessageNotReceivedException) {
66-
$response = new JsonResponse([
67-
'message' => $exception->getMessage(),
68-
], 422);
69-
$event->setResponse($response);
70-
} elseif ($exception instanceof Exception) {
71-
$response = new JsonResponse([
72-
'message' => $exception->getMessage(),
73-
], 500);
51+
if ($exception instanceof HttpExceptionInterface) {
52+
$event->setResponse(
53+
new JsonResponse([
54+
'message' => $exception->getMessage()
55+
], $exception->getStatusCode())
56+
);
57+
return;
58+
}
7459

75-
$event->setResponse($response);
60+
if ($exception instanceof Exception) {
61+
$event->setResponse(
62+
new JsonResponse([
63+
'message' => $exception->getMessage()
64+
], 500)
65+
);
7666
}
7767
}
7868
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\RestBundle\Messaging\Controller;
6+
7+
use OpenApi\Attributes as OA;
8+
use PhpList\Core\Domain\Messaging\Model\Attachment;
9+
use PhpList\Core\Domain\Messaging\Service\AttachmentDownloadService;
10+
use PhpList\Core\Security\Authentication;
11+
use PhpList\RestBundle\Common\Controller\BaseController;
12+
use PhpList\RestBundle\Common\Validator\RequestValidator;
13+
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
14+
use Symfony\Component\HttpFoundation\HeaderUtils;
15+
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
16+
use Symfony\Component\HttpFoundation\StreamedResponse;
17+
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
18+
use Symfony\Component\Routing\Attribute\Route;
19+
20+
#[Route('/attachments', name: 'attachments_')]
21+
class AttachmentController extends BaseController
22+
{
23+
public function __construct(
24+
Authentication $authentication,
25+
RequestValidator $validator,
26+
private readonly AttachmentDownloadService $attachmentDownloadService,
27+
) {
28+
parent::__construct($authentication, $validator);
29+
}
30+
31+
#[Route('/download/{id}', name: 'download', requirements: ['id' => '\\d+'], methods: ['GET'])]
32+
#[OA\Get(
33+
path: '/api/v2/attachments/download/{id}',
34+
description: 'Download an attachment by ID. `uid` query parameter is required.',
35+
summary: 'Download attachment',
36+
tags: ['attachments'],
37+
parameters: [
38+
new OA\Parameter(
39+
name: 'id',
40+
description: 'Attachment ID',
41+
in: 'path',
42+
required: true,
43+
schema: new OA\Schema(type: 'integer')
44+
),
45+
new OA\Parameter(
46+
name: 'uid',
47+
description: 'Download token (subscriber email or word "forwarded")',
48+
in: 'query',
49+
required: true,
50+
schema: new OA\Schema(type: 'string')
51+
),
52+
],
53+
responses: [
54+
new OA\Response(response: 200, description: 'File stream'),
55+
new OA\Response(response: 403, description: 'Unauthorized'),
56+
new OA\Response(response: 404, description: 'Not found'),
57+
]
58+
)]
59+
public function download(
60+
#[MapEntity(mapping: ['id' => 'id'])] Attachment $attachment,
61+
#[MapQueryParameter] string $uid
62+
): StreamedResponse {
63+
$downloadable = $this->attachmentDownloadService->getDownloadable($attachment, $uid);
64+
65+
$headers = [
66+
'Content-Type' => $downloadable->mimeType,
67+
'Content-Disposition' => HeaderUtils::makeDisposition(
68+
disposition: ResponseHeaderBag::DISPOSITION_ATTACHMENT,
69+
filename: $downloadable->filename
70+
),
71+
];
72+
73+
if ($downloadable->size !== null) {
74+
$headers['Content-Length'] = (string) $downloadable->size;
75+
}
76+
77+
return new StreamedResponse(
78+
callback: function () use ($downloadable) {
79+
$stream = $downloadable->content;
80+
81+
if ($stream->isSeekable()) {
82+
$stream->rewind();
83+
}
84+
85+
while (!$stream->eof()) {
86+
echo $stream->read(8192);
87+
flush();
88+
}
89+
},
90+
status: 200,
91+
headers: $headers
92+
);
93+
}
94+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\RestBundle\Tests\Integration\Messaging\Controller;
6+
7+
use PhpList\Core\Domain\Messaging\Model\Attachment;
8+
use PhpList\RestBundle\Messaging\Controller\AttachmentController;
9+
use PhpList\RestBundle\Tests\Integration\Common\AbstractTestController;
10+
use PhpList\RestBundle\Tests\Integration\Messaging\Fixtures\AttachmentFixture;
11+
12+
class AttachmentControllerTest extends AbstractTestController
13+
{
14+
private string $repoPath;
15+
16+
protected function setUp(): void
17+
{
18+
parent::setUp();
19+
$this->repoPath = (string) self::getContainer()->getParameter('phplist.attachment_repository_path');
20+
if (!is_dir($this->repoPath)) {
21+
mkdir($this->repoPath, 0777, true);
22+
}
23+
}
24+
25+
protected function tearDown(): void
26+
{
27+
// Clean up any test file we might have created
28+
$file = $this->repoPath . DIRECTORY_SEPARATOR . AttachmentFixture::FILENAME;
29+
if (is_file($file)) {
30+
unlink($file);
31+
}
32+
33+
parent::tearDown();
34+
}
35+
36+
public function testControllerIsAvailableViaContainer(): void
37+
{
38+
self::assertInstanceOf(
39+
AttachmentController::class,
40+
self::getContainer()->get(AttachmentController::class)
41+
);
42+
}
43+
44+
public function testDownloadReturnsFileStreamWithHeaders(): void
45+
{
46+
$this->loadFixtures([AttachmentFixture::class]);
47+
48+
// Prepare the actual file in the repository path
49+
$content = 'Hello Attachment';
50+
$file = $this->repoPath . DIRECTORY_SEPARATOR . AttachmentFixture::FILENAME;
51+
file_put_contents($file, $content);
52+
53+
self::getClient()->request(
54+
'GET',
55+
sprintf(
56+
'/api/v2/attachments/download/%d?uid=%s',
57+
AttachmentFixture::ATTACHMENT_ID,
58+
Attachment::FORWARD
59+
)
60+
);
61+
62+
$response = self::getClient()->getResponse();
63+
64+
// StreamedResponse should be 200 with correct headers
65+
self::assertSame(200, $response->getStatusCode());
66+
self::assertSame('text/plain; charset=UTF-8', $response->headers->get('Content-Type'));
67+
self::assertStringContainsString(
68+
'attachment; filename=' . AttachmentFixture::FILENAME,
69+
(string) $response->headers->get('Content-Disposition')
70+
);
71+
self::assertSame((string) strlen($content), $response->headers->get('Content-Length'));
72+
73+
$callback = $response->getCallback();
74+
ob_start();
75+
$callback();
76+
$body = ob_get_clean();
77+
78+
self::assertSame($content, $body);
79+
}
80+
81+
public function testDownloadReturnsNotFoundWhenAttachmentEntityMissing(): void
82+
{
83+
self::getClient()->request('GET', '/api/v2/attachments/download/999999?uid=' . Attachment::FORWARD);
84+
$this->assertHttpNotFound();
85+
}
86+
87+
public function testDownloadReturnsNotFoundWhenUidEmailNotFound(): void
88+
{
89+
$this->loadFixtures([AttachmentFixture::class]);
90+
91+
// Do not create the file; the uid validation happens first and should 404
92+
self::getClient()->request(
93+
'GET',
94+
sprintf(
95+
'/api/v2/attachments/download/%d?uid=%s',
96+
AttachmentFixture::ATTACHMENT_ID,
97+
'does-not-exist@example.com'
98+
)
99+
);
100+
101+
$this->assertHttpNotFound();
102+
}
103+
104+
public function testDownloadReturnsNotFoundWhenFileMissing(): void
105+
{
106+
$this->loadFixtures([AttachmentFixture::class]);
107+
108+
// Ensure no file exists
109+
$file = $this->repoPath . DIRECTORY_SEPARATOR . AttachmentFixture::FILENAME;
110+
if (is_file($file)) {
111+
unlink($file);
112+
}
113+
114+
self::getClient()->request(
115+
'GET',
116+
sprintf(
117+
'/api/v2/attachments/download/%d?uid=%s',
118+
AttachmentFixture::ATTACHMENT_ID,
119+
Attachment::FORWARD
120+
)
121+
);
122+
123+
$this->assertHttpNotFound();
124+
}
125+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\RestBundle\Tests\Integration\Messaging\Fixtures;
6+
7+
use Doctrine\Bundle\FixturesBundle\Fixture;
8+
use Doctrine\Persistence\ObjectManager;
9+
use PhpList\Core\Domain\Messaging\Model\Attachment;
10+
use PhpList\Core\TestingSupport\Traits\ModelTestTrait;
11+
12+
class AttachmentFixture extends Fixture
13+
{
14+
use ModelTestTrait;
15+
16+
public const ATTACHMENT_ID = 1;
17+
public const FILENAME = 'attachment.txt';
18+
19+
public function load(ObjectManager $manager): void
20+
{
21+
$attachment = new Attachment(
22+
filename: self::FILENAME,
23+
remoteFile: null,
24+
mimeType: 'text/plain',
25+
description: 'Test attachment',
26+
size: null,
27+
);
28+
29+
$this->setSubjectId($attachment, self::ATTACHMENT_ID);
30+
$manager->persist($attachment);
31+
$manager->flush();
32+
}
33+
}

0 commit comments

Comments
 (0)