Skip to content

Commit 814a5f7

Browse files
committed
EditorUploadController
1 parent 3fa9ecd commit 814a5f7

4 files changed

Lines changed: 259 additions & 1 deletion

File tree

src/Common/EventListener/ExceptionListener.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@
1111
use PhpList\Core\Domain\Messaging\Exception\SubscriberNotFoundException;
1212
use PhpList\Core\Domain\Subscription\Exception\AttributeDefinitionCreationException;
1313
use PhpList\Core\Domain\Subscription\Exception\SubscriptionCreationException;
14+
use PhpList\Core\Domain\Common\Upload\Exception\InvalidUploadException;
15+
use PhpList\Core\Domain\Common\Upload\Exception\MissingUploadException;
16+
use PhpList\Core\Domain\Common\Upload\Exception\StorageException;
17+
use PhpList\Core\Domain\Common\Upload\Exception\UnsupportedExtensionException;
18+
use PhpList\Core\Domain\Common\Upload\Exception\UnsupportedMimeTypeException;
19+
use PhpList\Core\Domain\Common\Upload\Exception\UploadTooLargeException;
1420
use Symfony\Component\HttpFoundation\JsonResponse;
1521
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
1622
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
@@ -31,6 +37,12 @@ class ExceptionListener
3137
AttachmentFileNotFoundException::class => 404,
3238
SubscriberNotFoundException::class => 404,
3339
MessageNotReceivedException::class => 422,
40+
MissingUploadException::class => 400,
41+
InvalidUploadException::class => 400,
42+
UnsupportedExtensionException::class => 400,
43+
UnsupportedMimeTypeException::class => 415,
44+
UploadTooLargeException::class => 413,
45+
StorageException::class => 500,
3446
];
3547

3648
public function onKernelException(ExceptionEvent $event): void
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
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\Security\Authentication;
9+
use PhpList\RestBundle\Common\Controller\BaseController;
10+
use PhpList\Core\Domain\Common\Upload\UploadService;
11+
use PhpList\RestBundle\Common\Validator\RequestValidator;
12+
use Symfony\Component\HttpFoundation\File\UploadedFile;
13+
use Symfony\Component\HttpFoundation\JsonResponse;
14+
use Symfony\Component\HttpFoundation\Request;
15+
use Symfony\Component\HttpFoundation\Response;
16+
use Symfony\Component\Routing\Attribute\Route;
17+
18+
#[Route('/editor-uploads', name: 'editor_uploads_')]
19+
class EditorUploadController extends BaseController
20+
{
21+
public function __construct(
22+
Authentication $authentication,
23+
RequestValidator $validator,
24+
private readonly UploadService $uploadService,
25+
) {
26+
parent::__construct($authentication, $validator);
27+
}
28+
29+
#[Route('', name: 'upload', methods: ['POST'])]
30+
#[OA\Post(
31+
path: '/api/v2/editor-uploads',
32+
description: 'Uploads an editor asset for use in CKEditor.',
33+
summary: 'Upload editor asset',
34+
requestBody: new OA\RequestBody(
35+
required: true,
36+
content: new OA\MediaType(
37+
mediaType: 'multipart/form-data',
38+
schema: new OA\Schema(
39+
properties: [
40+
new OA\Property(
41+
property: 'upload',
42+
description: 'Asset file to upload',
43+
type: 'string',
44+
format: 'binary'
45+
),
46+
new OA\Property(
47+
property: 'file',
48+
description: 'Alternative asset file field name',
49+
type: 'string',
50+
format: 'binary'
51+
),
52+
],
53+
type: 'object'
54+
)
55+
)
56+
),
57+
tags: ['editor-uploads'],
58+
parameters: [
59+
new OA\Parameter(
60+
name: 'php-auth-pw',
61+
description: 'Session key obtained from login',
62+
in: 'header',
63+
required: true,
64+
schema: new OA\Schema(type: 'string')
65+
),
66+
],
67+
responses: [
68+
new OA\Response(
69+
response: 201,
70+
description: 'Asset uploaded',
71+
content: new OA\JsonContent(
72+
properties: [
73+
new OA\Property(property: 'uploaded', type: 'boolean', example: true),
74+
new OA\Property(property: 'fileName', type: 'string', example: 'f47ac10b58cc4372a5670e02b2c3d479.png'),
75+
new OA\Property(property: 'url', type: 'string', example: 'https://example.com/uploadfiles/f47ac10b58cc4372a5670e02b2c3d479.png'),
76+
new OA\Property(property: 'default', type: 'string', example: 'https://example.com/uploadfiles/f47ac10b58cc4372a5670e02b2c3d479.png'),
77+
new OA\Property(property: 'mimeType', type: 'string', example: 'image/png'),
78+
new OA\Property(property: 'size', type: 'integer', example: 123456, nullable: true),
79+
new OA\Property(property: 'extension', type: 'string', example: 'png'),
80+
],
81+
type: 'object'
82+
)
83+
),
84+
new OA\Response(
85+
response: 400,
86+
description: 'Bad request',
87+
content: new OA\JsonContent(ref: '#/components/schemas/BadRequestResponse')
88+
),
89+
new OA\Response(
90+
response: 401,
91+
description: 'Unauthorized',
92+
content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse')
93+
),
94+
new OA\Response(
95+
response: 413,
96+
description: 'Payload too large',
97+
content: new OA\JsonContent(ref: '#/components/schemas/GenericErrorResponse')
98+
),
99+
new OA\Response(
100+
response: 500,
101+
description: 'Storage failure',
102+
content: new OA\JsonContent(ref: '#/components/schemas/GenericErrorResponse')
103+
),
104+
]
105+
)]
106+
public function uploadAsset(Request $request): JsonResponse
107+
{
108+
$this->requireAuthentication($request);
109+
110+
/** @var UploadedFile|null $uploadedFile */
111+
$uploadedFile = $request->files->get('upload') ?? $request->files->get('file');
112+
$uploadResult = $this->uploadService->upload($uploadedFile);
113+
114+
return new JsonResponse(
115+
[
116+
'uploaded' => true,
117+
'fileName' => $uploadResult->getFilename(),
118+
'url' => $uploadResult->getUrl(),
119+
'default' => $uploadResult->getUrl(),
120+
'mimeType' => $uploadResult->getMimeType(),
121+
'size' => $uploadResult->getSize(),
122+
'extension' => $uploadResult->getExtension(),
123+
],
124+
Response::HTTP_CREATED
125+
);
126+
}
127+
}

tests/Integration/Common/EventListener/ExceptionListenerTest.php

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@
66

77
use PhpList\Core\Domain\Subscription\Exception\SubscriptionCreationException;
88
use PhpList\RestBundle\Common\EventListener\ExceptionListener;
9+
use PhpList\RestBundle\Common\Upload\Exception\InvalidUploadException;
10+
use PhpList\RestBundle\Common\Upload\Exception\MissingUploadException;
11+
use PhpList\RestBundle\Common\Upload\Exception\StorageException;
12+
use PhpList\RestBundle\Common\Upload\Exception\UnsupportedMimeTypeException;
13+
use PhpList\RestBundle\Common\Upload\Exception\UploadTooLargeException;
914
use PHPUnit\Framework\TestCase;
1015
use Symfony\Component\HttpFoundation\JsonResponse;
1116
use Symfony\Component\HttpFoundation\Request;
@@ -33,7 +38,7 @@ public function testAccessDeniedExceptionHandled(): void
3338
$response = $event->getResponse();
3439

3540
$this->assertInstanceOf(JsonResponse::class, $response);
36-
$this->assertEquals(403, $response->getStatusCode());
41+
$this->assertEquals(401, $response->getStatusCode());
3742
$this->assertEquals(
3843
['message' => 'Forbidden'],
3944
json_decode($response->getContent(), true)
@@ -88,4 +93,52 @@ public function testGenericExceptionHandled(): void
8893
json_decode($response->getContent(), true)
8994
);
9095
}
96+
97+
public function testMissingUploadExceptionHandled(): void
98+
{
99+
$listener = new ExceptionListener();
100+
$event = $this->createExceptionEvent(new MissingUploadException('No file uploaded.'));
101+
102+
$listener->onKernelException($event);
103+
$response = $event->getResponse();
104+
105+
$this->assertInstanceOf(JsonResponse::class, $response);
106+
$this->assertEquals(400, $response->getStatusCode());
107+
}
108+
109+
public function testUnsupportedMimeTypeExceptionHandled(): void
110+
{
111+
$listener = new ExceptionListener();
112+
$event = $this->createExceptionEvent(new UnsupportedMimeTypeException('Unsupported MIME type.'));
113+
114+
$listener->onKernelException($event);
115+
$response = $event->getResponse();
116+
117+
$this->assertInstanceOf(JsonResponse::class, $response);
118+
$this->assertEquals(415, $response->getStatusCode());
119+
}
120+
121+
public function testUploadTooLargeExceptionHandled(): void
122+
{
123+
$listener = new ExceptionListener();
124+
$event = $this->createExceptionEvent(new UploadTooLargeException('Too large.'));
125+
126+
$listener->onKernelException($event);
127+
$response = $event->getResponse();
128+
129+
$this->assertInstanceOf(JsonResponse::class, $response);
130+
$this->assertEquals(413, $response->getStatusCode());
131+
}
132+
133+
public function testStorageExceptionHandled(): void
134+
{
135+
$listener = new ExceptionListener();
136+
$event = $this->createExceptionEvent(new StorageException('Storage failed.'));
137+
138+
$listener->onKernelException($event);
139+
$response = $event->getResponse();
140+
141+
$this->assertInstanceOf(JsonResponse::class, $response);
142+
$this->assertEquals(500, $response->getStatusCode());
143+
}
91144
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\RestBundle\Tests\Unit\Messaging\Controller;
6+
7+
use PhpList\Core\Domain\Identity\Model\Administrator;
8+
use PhpList\Core\Security\Authentication;
9+
use PhpList\Core\Domain\Common\Upload\UploadResult;
10+
use PhpList\Core\Domain\Common\Upload\UploadService;
11+
use PhpList\RestBundle\Common\Validator\RequestValidator;
12+
use PhpList\RestBundle\Messaging\Controller\EditorUploadController;
13+
use PHPUnit\Framework\TestCase;
14+
use Symfony\Component\HttpFoundation\File\UploadedFile;
15+
use Symfony\Component\HttpFoundation\Request;
16+
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
17+
18+
class EditorUploadControllerTest extends TestCase
19+
{
20+
public function testUploadAssetReturnsJsonResponse(): void
21+
{
22+
$authentication = $this->createMock(Authentication::class);
23+
$validator = $this->createMock(RequestValidator::class);
24+
$uploadService = $this->createMock(UploadService::class);
25+
$request = new Request();
26+
$file = $this->createMock(UploadedFile::class);
27+
$request->files->set('upload', $file);
28+
29+
$authentication->method('authenticateByApiKey')->willReturn($this->createMock(Administrator::class));
30+
$uploadService->expects(self::once())->method('upload')->with($file)->willReturn(
31+
new UploadResult('stored.png', 'https://example.test/uploadfiles/stored.png', 'image/png', 123, 'png')
32+
);
33+
34+
$controller = new EditorUploadController($authentication, $validator, $uploadService);
35+
$response = $controller->uploadAsset($request);
36+
37+
self::assertSame(201, $response->getStatusCode());
38+
self::assertSame(
39+
[
40+
'uploaded' => true,
41+
'fileName' => 'stored.png',
42+
'url' => 'https://example.test/uploadfiles/stored.png',
43+
'default' => 'https://example.test/uploadfiles/stored.png',
44+
'mimeType' => 'image/png',
45+
'size' => 123,
46+
'extension' => 'png',
47+
],
48+
json_decode($response->getContent(), true)
49+
);
50+
}
51+
52+
public function testUploadAssetRequiresAuthentication(): void
53+
{
54+
$authentication = $this->createMock(Authentication::class);
55+
$validator = $this->createMock(RequestValidator::class);
56+
$uploadService = $this->createMock(UploadService::class);
57+
$request = new Request();
58+
59+
$authentication->method('authenticateByApiKey')->willReturn(null);
60+
61+
$controller = new EditorUploadController($authentication, $validator, $uploadService);
62+
63+
$this->expectException(AccessDeniedHttpException::class);
64+
$controller->uploadAsset($request);
65+
}
66+
}

0 commit comments

Comments
 (0)