Skip to content

Commit 2444d55

Browse files
committed
feat: file upload in an existing resource
1 parent abbe762 commit 2444d55

File tree

1 file changed

+205
-66
lines changed

1 file changed

+205
-66
lines changed

core/file-upload.md

Lines changed: 205 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,16 @@ vich_uploader:
3333
namer: Vich\UploaderBundle\Naming\OrignameNamer
3434
```
3535
36-
## Configuring the Entity Receiving the Uploaded File
36+
## Uploading to a Dedicated Resource
3737
38-
In our example, we will create a `MediaObject` API resource. We will post files
38+
In this example, we will create a `MediaObject` API resource. We will post files
3939
to this resource endpoint, and then link the newly created resource to another
40-
resource (in our case: Book).
40+
resource (in our case: `Book`).
41+
42+
This example will use a custom controller to receive the file.
43+
The second example will use a custom `multipart/form-data` decoder to deserialize the resource instead.
44+
45+
### Configuring the Resource Receiving the Uploaded File
4146

4247
The `MediaObject` resource is implemented like this:
4348

@@ -57,76 +62,61 @@ use Vich\UploaderBundle\Mapping\Annotation as Vich;
5762
5863
/**
5964
* @ORM\Entity
60-
* @ApiResource(
61-
* iri="http://schema.org/MediaObject",
62-
* normalizationContext={
63-
* "groups"={"media_object_read"}
64-
* },
65-
* collectionOperations={
66-
* "post"={
67-
* "controller"=CreateMediaObjectAction::class,
68-
* "deserialize"=false,
69-
* "security"="is_granted('ROLE_USER')",
70-
* "validation_groups"={"Default", "media_object_create"},
71-
* "openapi_context"={
72-
* "requestBody"={
73-
* "content"={
74-
* "multipart/form-data"={
75-
* "schema"={
76-
* "type"="object",
77-
* "properties"={
78-
* "file"={
79-
* "type"="string",
80-
* "format"="binary"
81-
* }
82-
* }
83-
* }
84-
* }
85-
* }
86-
* }
87-
* }
88-
* },
89-
* "get"
90-
* },
91-
* itemOperations={
92-
* "get"
93-
* }
94-
* )
9565
* @Vich\Uploadable
9666
*/
67+
#[ApiResource(
68+
iri: 'http://schema.org/MediaObject',
69+
normalizationContext: ['groups' => ['media_object:read']],
70+
itemOperations: ['get'],
71+
collectionOperations: [
72+
'get',
73+
'post' => [
74+
'controller' => CreateMediaObjectAction::class,
75+
'deserialize' => false,
76+
'validation_groups' => ['Default', 'media_object_create'],
77+
'openapi_context' => [
78+
'requestBody' => [
79+
'content' => [
80+
'multipart/form-data' => [
81+
'schema' => [
82+
'type' => 'object',
83+
'properties' => [
84+
'file' => [
85+
'type' => 'string',
86+
'format' => 'binary',
87+
],
88+
],
89+
],
90+
],
91+
],
92+
],
93+
],
94+
],
95+
]
96+
)]
9797
class MediaObject
9898
{
9999
/**
100-
* @var int|null
101-
*
102100
* @ORM\Column(type="integer")
103101
* @ORM\GeneratedValue
104102
* @ORM\Id
105103
*/
106-
protected $id;
104+
private ?int $id = null;
107105
108-
/**
109-
* @var string|null
110-
*
111-
* @ApiProperty(iri="http://schema.org/contentUrl")
112-
* @Groups({"media_object_read"})
113-
*/
114-
public $contentUrl;
106+
#[ApiProperty(iri: 'http://schema.org/contentUrl')]
107+
#[Groups(['media_object:read'])]
108+
public ?string $contentUrl = null;
115109
116110
/**
117-
* @var File|null
118-
*
119-
* @Assert\NotNull(groups={"media_object_create"})
120111
* @Vich\UploadableField(mapping="media_object", fileNameProperty="filePath")
121112
*/
122-
public $file;
113+
#[Assert\NotNull(groups: ['media_object_create'])]
114+
public ?File $file = null;
123115
124116
/**
125-
* @var string|null
126-
*
127117
* @ORM\Column(nullable=true)
128118
*/
129-
public $filePath;
119+
public ?string $filePath = null;
130120
131121
public function getId(): ?int
132122
{
@@ -135,7 +125,7 @@ class MediaObject
135125
}
136126
```
137127

138-
## The Controller
128+
### Creating the Controller
139129

140130
At this point, the entity is configured, but we still need to write the action
141131
that handles the file upload.
@@ -167,7 +157,7 @@ final class CreateMediaObjectAction
167157
}
168158
```
169159

170-
## Resolving the File URL
160+
### Resolving the File URL
171161

172162
Returning the plain file path on the filesystem where the file is stored is not useful for the client, which needs a
173163
URL to work with.
@@ -216,7 +206,7 @@ final class MediaObjectNormalizer implements ContextAwareNormalizerInterface, No
216206
}
217207
```
218208

219-
## Making a Request to the `/media_objects` Endpoint
209+
### Making a Request to the `/media_objects` Endpoint
220210

221211
Your `/media_objects` endpoint is now ready to receive a `POST` request with a
222212
file. This endpoint accepts standard `multipart/form-data`-encoded data, but
@@ -231,7 +221,7 @@ your data, you will get a response looking like this:
231221
}
232222
```
233223

234-
## Accessing Your Media Objects Directly
224+
### Accessing Your Media Objects Directly
235225

236226
You will need to modify your Caddyfile to allow the above `contentUrl` to be accessed directly. If you followed the above configuration for the VichUploaderBundle, that will be in `api/public/media`. Add your folder to the list of path matches, e.g. `|^/media/|`:
237227
```caddyfile
@@ -244,7 +234,7 @@ You will need to modify your Caddyfile to allow the above `contentUrl` to be acc
244234
...
245235
```
246236

247-
## Linking a MediaObject Resource to Another Resource
237+
### Linking a MediaObject Resource to Another Resource
248238

249239
We now need to update our `Book` resource, so that we can link a `MediaObject`
250240
to serve as the book cover.
@@ -264,20 +254,18 @@ use Vich\UploaderBundle\Mapping\Annotation as Vich;
264254

265255
/**
266256
* @ORM\Entity
267-
* @ApiResource(iri="http://schema.org/Book")
268257
*/
258+
#[ApiResource(iri: 'http://schema.org/Book')]
269259
class Book
270260
{
271261
// ...
272262

273263
/**
274-
* @var MediaObject|null
275-
*
276264
* @ORM\ManyToOne(targetEntity=MediaObject::class)
277265
* @ORM\JoinColumn(nullable=true)
278-
* @ApiProperty(iri="http://schema.org/image")
279266
*/
280-
public $image;
267+
#[ApiProperty(iri: 'http://schema.org/image')]
268+
public ?MediaObject $image = null;
281269

282270
// ...
283271
}
@@ -298,7 +286,7 @@ uploaded cover, you can have a nice illustrated book record!
298286
Voilà! You can now send files to your API, and link them to any other resource
299287
in your app.
300288

301-
## Testing
289+
### Testing
302290

303291
To test your upload with `ApiTestCase`, you can write a method as below:
304292

@@ -342,3 +330,154 @@ class MediaObjectTest extends ApiTestCase
342330
}
343331
}
344332
```
333+
334+
## Uploading to an Existing Resource with its Fields
335+
336+
In this example, the file will be included in an existing resource (in our case: `Book`).
337+
The file and the resource fields will be posted to the resource endpoint.
338+
339+
This example will use a custom `multipart/form-data` decoder to deserialize the resource instead of a custom controller.
340+
341+
### Configuring the Existing Resource Receiving the Uploaded File
342+
343+
The `Book` resource needs to be modified like this:
344+
345+
```php
346+
<?php
347+
// api/src/Entity/Book.php
348+
namespace App\Entity;
349+
350+
use ApiPlatform\Core\Annotation\ApiProperty;
351+
use ApiPlatform\Core\Annotation\ApiResource;
352+
use Doctrine\ORM\Mapping as ORM;
353+
use Symfony\Component\HttpFoundation\File\File;
354+
use Symfony\Component\Serializer\Annotation\Groups;
355+
use Vich\UploaderBundle\Mapping\Annotation as Vich;
356+
357+
/**
358+
* @ORM\Entity
359+
* @Vich\Uploadable
360+
*/
361+
#[ApiResource(
362+
iri: 'http://schema.org/Book',
363+
normalizationContext: ['groups' => ['book:read']],
364+
denormalizationContext: ['groups' => ['book:write']],
365+
collectionOperations: [
366+
'get',
367+
'post' => [
368+
'input_formats' => [
369+
'multipart' => ['multipart/form-data'],
370+
],
371+
],
372+
],
373+
)]
374+
class Book
375+
{
376+
// ...
377+
378+
#[ApiProperty(iri: 'http://schema.org/contentUrl')]
379+
#[Groups(['book:read'])]
380+
public ?string $contentUrl = null;
381+
382+
/**
383+
* @Vich\UploadableField(mapping="media_object", fileNameProperty="filePath")
384+
*/
385+
#[Groups(['book:write'])]
386+
public ?File $file = null;
387+
388+
/**
389+
* @ORM\Column(nullable=true)
390+
*/
391+
public ?string $filePath = null;
392+
393+
// ...
394+
}
395+
```
396+
397+
### Handling the Multipart Deserialization
398+
399+
By default, Symfony is not able to decode `multipart/form-data`-encoded data.
400+
We need to create our own decoder to do it:
401+
402+
```php
403+
<?php
404+
// api/src/Encoder/MultipartDecoder.php
405+
406+
namespace App\Encoder;
407+
408+
use Symfony\Component\HttpFoundation\RequestStack;
409+
use Symfony\Component\Serializer\Encoder\DecoderInterface;
410+
411+
final class MultipartDecoder implements DecoderInterface
412+
{
413+
public const FORMAT = 'multipart';
414+
415+
public function __construct(private RequestStack $requestStack) {}
416+
417+
/**
418+
* {@inheritdoc}
419+
*/
420+
public function decode(string $data, string $format, array $context = []): ?array
421+
{
422+
$request = $this->requestStack->getCurrentRequest();
423+
424+
if (!$request) {
425+
return null;
426+
}
427+
428+
return array_map(static function (string $element) {
429+
// Multipart form values will be encoded in JSON.
430+
$decoded = json_decode($element, true);
431+
432+
return \is_array($decoded) ? $decoded : $element;
433+
}, $request->request->all()) + $request->files->all();
434+
}
435+
436+
/**
437+
* {@inheritdoc}
438+
*/
439+
public function supportsDecoding(string $format): bool
440+
{
441+
return self::FORMAT === $format;
442+
}
443+
}
444+
```
445+
446+
If you're not using `autowiring` and `autoconfiguring`, don't forget to register the service and tag it as `serializer.encoder`.
447+
448+
We also need to make sure the field containing the uploaded file is not denormalized:
449+
450+
```php
451+
<?php
452+
// api/src/Serializer/UploadedFileDenormalizer.php
453+
454+
namespace App\Serializer;
455+
456+
use Symfony\Component\HttpFoundation\File\UploadedFile;
457+
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
458+
459+
final class UploadedFileDenormalizer implements DenormalizerInterface
460+
{
461+
/**
462+
* {@inheritdoc}
463+
*/
464+
public function denormalize($data, string $type, string $format = null, array $context = []): UploadedFile
465+
{
466+
return $data;
467+
}
468+
469+
/**
470+
* {@inheritdoc}
471+
*/
472+
public function supportsDenormalization($data, $type, $format = null): bool
473+
{
474+
return $data instanceof UploadedFile;
475+
}
476+
}
477+
```
478+
479+
If you're not using `autowiring` and `autoconfiguring`, don't forget to register the service and tag it as `serializer.normalizer`.
480+
481+
### Resolving the File URL
482+
483+
For resolving the file URL, you can use a custom normalizer, like shown in [the previous example](#resolving-the-file-url).

0 commit comments

Comments
 (0)