Skip to content

Commit 2d69142

Browse files
teohhanhuidunglas
authored andcommitted
Simplify file upload handling (#550)
* Simplify file upload handling Also add section on resolving file URL * Add swagger_context for file upload Co-Authored-By: teohhanhui <[email protected]>
1 parent 9ccea9b commit 2d69142

File tree

1 file changed

+136
-67
lines changed

1 file changed

+136
-67
lines changed

core/file-upload.md

Lines changed: 136 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ before proceeding. It will help you get a grasp on how the bundle works, and why
1010

1111
## Installing VichUploaderBundle
1212

13-
Install the bundle with the help of composer:
13+
Install the bundle with the help of Composer:
1414

1515
```bash
1616
docker-compose exec php composer require vich/uploader-bundle
@@ -20,7 +20,7 @@ This will create a new configuration file that you will need to slightly change
2020
to make it look like this.
2121

2222
```yaml
23-
# config/packages/vich_uploader.yaml
23+
# api/config/packages/vich_uploader.yaml
2424
vich_uploader:
2525
db_driver: orm
2626

@@ -49,40 +49,74 @@ use ApiPlatform\Core\Annotation\ApiResource;
4949
use App\Controller\CreateMediaObjectAction;
5050
use Doctrine\ORM\Mapping as ORM;
5151
use Symfony\Component\HttpFoundation\File\File;
52+
use Symfony\Component\Serializer\Annotation\Groups;
5253
use Symfony\Component\Validator\Constraints as Assert;
5354
use Vich\UploaderBundle\Mapping\Annotation as Vich;
5455
5556
/**
5657
* @ORM\Entity
57-
* @ApiResource(iri="http://schema.org/MediaObject", collectionOperations={
58-
* "get",
59-
* "post"={
60-
* "method"="POST",
61-
* "path"="/media_objects",
62-
* "controller"=CreateMediaObjectAction::class,
63-
* "defaults"={"_api_receive"=false},
58+
* @ApiResource(
59+
* iri="http://schema.org/MediaObject",
60+
* normalizationContext={
61+
* "groups"={"media_object_read"},
6462
* },
65-
* })
63+
* collectionOperations={
64+
* "post"={
65+
* "controller"=CreateMediaObjectAction::class,
66+
* "defaults"={
67+
* "_api_receive"=false,
68+
* },
69+
* "access_control"="is_granted('ROLE_USER')",
70+
* "validation_groups"={"Default", "media_object_create"},
71+
* "swagger_context"={
72+
* "consumes"={
73+
* "multipart/form-data",
74+
* },
75+
* "parameters"={
76+
* {
77+
* "in"="formData",
78+
* "name"="file",
79+
* "type"="file",
80+
* "description"="The file to upload",
81+
* },
82+
* },
83+
* },
84+
* },
85+
* "get",
86+
* },
87+
* itemOperations={
88+
* "get",
89+
* },
90+
* )
6691
* @Vich\Uploadable
6792
*/
6893
class MediaObject
6994
{
7095
// ...
7196
97+
/**
98+
* @var string|null
99+
*
100+
* @ApiProperty(iri="http://schema.org/contentUrl")
101+
* @Groups({"media_object_read"})
102+
*/
103+
public $contentUrl;
104+
72105
/**
73106
* @var File|null
74-
* @Assert\NotNull()
75-
* @Vich\UploadableField(mapping="media_object", fileNameProperty="contentUrl")
107+
*
108+
* @Assert\NotNull(groups={"media_object_create"})
109+
* @Vich\UploadableField(mapping="media_object", fileNameProperty="filePath")
76110
*/
77111
public $file;
78112
79113
/**
80114
* @var string|null
115+
*
81116
* @ORM\Column(nullable=true)
82-
* @ApiProperty(iri="http://schema.org/contentUrl")
83117
*/
84-
public $contentUrl;
85-
118+
public $filePath;
119+
86120
// ...
87121
}
88122
```
@@ -99,91 +133,125 @@ that handles the file upload.
99133
namespace App\Controller;
100134
101135
use ApiPlatform\Core\Bridge\Symfony\Validator\Exception\ValidationException;
136+
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
137+
use ApiPlatform\Core\Util\RequestAttributesExtractor;
138+
use ApiPlatform\Core\Validator\ValidatorInterface;
102139
use App\Entity\MediaObject;
103-
use App\Form\MediaObjectType;
104-
use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
105-
use Symfony\Bridge\Doctrine\RegistryInterface;
106-
use Symfony\Component\Form\FormFactoryInterface;
140+
use Doctrine\Common\Persistence\ManagerRegistry;
107141
use Symfony\Component\HttpFoundation\Request;
108-
use Symfony\Component\Validator\Validator\ValidatorInterface;
142+
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
109143
110144
final class CreateMediaObjectAction
111145
{
146+
private $managerRegistry;
112147
private $validator;
113-
private $doctrine;
114-
private $factory;
148+
private $resourceMetadataFactory;
115149
116-
public function __construct(RegistryInterface $doctrine, FormFactoryInterface $factory, ValidatorInterface $validator)
150+
public function __construct(ManagerRegistry $managerRegistry, ValidatorInterface $validator, ResourceMetadataFactoryInterface $resourceMetadataFactory)
117151
{
152+
$this->managerRegistry = $managerRegistry;
118153
$this->validator = $validator;
119-
$this->doctrine = $doctrine;
120-
$this->factory = $factory;
154+
$this->resourceMetadataFactory = $resourceMetadataFactory;
121155
}
122156
123-
/**
124-
* @IsGranted("ROLE_USER")
125-
*/
126157
public function __invoke(Request $request): MediaObject
127158
{
159+
$uploadedFile = $request->files->get('file');
160+
161+
if (!$uploadedFile) {
162+
throw new BadRequestHttpException('"file" is required');
163+
}
164+
128165
$mediaObject = new MediaObject();
166+
$mediaObject->file = $uploadedFile;
129167
130-
$form = $this->factory->create(MediaObjectType::class, $mediaObject);
131-
$form->handleRequest($request);
132-
if ($form->isSubmitted() && $form->isValid()) {
133-
$em = $this->doctrine->getManager();
134-
$em->persist($mediaObject);
135-
$em->flush();
168+
$this->validate($mediaObject, $request);
136169
137-
// Prevent the serialization of the file property
138-
$mediaObject->file = null;
170+
$em = $this->managerRegistry->getManager();
171+
$em->persist($mediaObject);
172+
$em->flush();
139173
140-
return $mediaObject;
141-
}
174+
return $mediaObject;
175+
}
142176
143-
// This will be handled by API Platform and returns a validation error.
144-
throw new ValidationException($this->validator->validate($mediaObject));
177+
/**
178+
* @throws ValidationException
179+
*/
180+
private function validate(MediaObject $mediaObject, Request $request): void
181+
{
182+
$attributes = RequestAttributesExtractor::extractAttributes($request);
183+
$resourceMetadata = $this->resourceMetadataFactory->create(MediaObject::class);
184+
$validationGroups = $resourceMetadata->getOperationAttribute($attributes, 'validation_groups', null, true);
185+
186+
$this->validator->validate($mediaObject, ['groups' => $validationGroups]);
145187
}
146188
}
147189
```
148190

149-
As you can see, the action uses a form. You will need this form to be like this:
191+
## Resolving the File URL
192+
193+
Returning the plain file path on the filesystem where the file is stored is not useful for the client, which needs a
194+
URL to work with.
195+
196+
An [event subscriber](events.md) could be used to set the `contentUrl` property:
150197

151198
```php
152199
<?php
153-
// api/src/Form/MediaObjectType.php
200+
// api/src/EventSubscriber/ResolveMediaObjectContentUrlSubscriber.php
154201
155-
namespace App\Form;
202+
namespace App\EventSubscriber;
156203
204+
use ApiPlatform\Core\EventListener\EventPriorities;
205+
use ApiPlatform\Core\Util\RequestAttributesExtractor;
157206
use App\Entity\MediaObject;
158-
use Symfony\Component\Form\AbstractType;
159-
use Symfony\Component\Form\Extension\Core\Type\FileType;
160-
use Symfony\Component\Form\FormBuilderInterface;
161-
use Symfony\Component\OptionsResolver\OptionsResolver;
207+
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
208+
use Symfony\Component\HttpFoundation\Response;
209+
use Symfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent;
210+
use Symfony\Component\HttpKernel\KernelEvents;
211+
use Vich\UploaderBundle\Storage\StorageInterface;
162212
163-
final class MediaObjectType extends AbstractType
213+
final class ResolveMediaObjectContentUrlSubscriber implements EventSubscriberInterface
164214
{
165-
public function buildForm(FormBuilderInterface $builder, array $options)
215+
private $storage;
216+
217+
public function __construct(StorageInterface $storage)
166218
{
167-
$builder
168-
// Configure each field you want to be submitted here, like a classic form.
169-
->add('file', FileType::class, [
170-
'label' => 'label.file',
171-
'required' => false,
172-
])
173-
;
219+
$this->storage = $storage;
174220
}
175221
176-
public function configureOptions(OptionsResolver $resolver)
222+
public static function getSubscribedEvents(): array
177223
{
178-
$resolver->setDefaults([
179-
'data_class' => MediaObject::class,
180-
'csrf_protection' => false,
181-
]);
224+
return [
225+
KernelEvents::VIEW => ['onPreSerialize', EventPriorities::PRE_SERIALIZE],
226+
];
182227
}
183228
184-
public function getBlockPrefix()
229+
public function onPreSerialize(GetResponseForControllerResultEvent $event): void
185230
{
186-
return '';
231+
$controllerResult = $event->getControllerResult();
232+
$request = $event->getRequest();
233+
234+
if ($controllerResult instanceof Response || !$request->attributes->getBoolean('_api_respond', true)) {
235+
return;
236+
}
237+
238+
if (!$attributes = RequestAttributesExtractor::extractAttributes($request) || !\is_a($attributes['resource_class'], MediaObject::class, true)) {
239+
return;
240+
}
241+
242+
$mediaObjects = $controllerResult;
243+
244+
if (!is_iterable($mediaObjects)) {
245+
$mediaObjects = [$mediaObjects];
246+
}
247+
248+
foreach ($mediaObjects as $mediaObject) {
249+
if (!$mediaObject instanceof MediaObject) {
250+
continue;
251+
}
252+
253+
$mediaObject->contentUrl = $this->storage->resolveUri($mediaObject, 'file');
254+
}
187255
}
188256
}
189257
```
@@ -197,9 +265,9 @@ your data, you will get a response looking like this:
197265

198266
```json
199267
{
200-
"@type": "http://schema.org/ImageObject",
201-
"@id": "/media_objects/<id>",
202-
"contentUrl": "<filename>",
268+
"@type": "http://schema.org/MediaObject",
269+
"@id": "/media_objects/<id>",
270+
"contentUrl": "<url>"
203271
}
204272
```
205273

@@ -232,7 +300,8 @@ class Book
232300
233301
/**
234302
* @var MediaObject|null
235-
* @ORM\ManyToOne(targetEntity="App\Entity\MediaObject")
303+
*
304+
* @ORM\ManyToOne(targetEntity=MediaObject::class)
236305
* @ORM\JoinColumn(nullable=true)
237306
* @ApiProperty(iri="http://schema.org/image")
238307
*/

0 commit comments

Comments
 (0)