Skip to content

feat: file upload in an existing resource #1385

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
269 changes: 203 additions & 66 deletions core/file-upload.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,16 @@ vich_uploader:
namer: Vich\UploaderBundle\Naming\OrignameNamer
```

## Configuring the Entity Receiving the Uploaded File
## Uploading to a Dedicated Resource

In our example, we will create a `MediaObject` API resource. We will post files
In this example, we will create a `MediaObject` API resource. We will post files
to this resource endpoint, and then link the newly created resource to another
resource (in our case: Book).
resource (in our case: `Book`).

This example will use a custom controller to receive the file.
The second example will use a custom `multipart/form-data` decoder to deserialize the resource instead.

### Configuring the Resource Receiving the Uploaded File

The `MediaObject` resource is implemented like this:

Expand All @@ -57,76 +62,61 @@ use Vich\UploaderBundle\Mapping\Annotation as Vich;

/**
* @ORM\Entity
* @ApiResource(
* iri="http://schema.org/MediaObject",
* normalizationContext={
* "groups"={"media_object_read"}
* },
* collectionOperations={
* "post"={
* "controller"=CreateMediaObjectAction::class,
* "deserialize"=false,
* "security"="is_granted('ROLE_USER')",
* "validation_groups"={"Default", "media_object_create"},
* "openapi_context"={
* "requestBody"={
* "content"={
* "multipart/form-data"={
* "schema"={
* "type"="object",
* "properties"={
* "file"={
* "type"="string",
* "format"="binary"
* }
* }
* }
* }
* }
* }
* }
* },
* "get"
* },
* itemOperations={
* "get"
* }
* )
* @Vich\Uploadable
*/
#[ApiResource(
iri: 'http://schema.org/MediaObject',
normalizationContext: ['groups' => ['media_object:read']],
itemOperations: ['get'],
collectionOperations: [
'get',
'post' => [
'controller' => CreateMediaObjectAction::class,
'deserialize' => false,
'validation_groups' => ['Default', 'media_object_create'],
'openapi_context' => [
'requestBody' => [
'content' => [
'multipart/form-data' => [
'schema' => [
'type' => 'object',
'properties' => [
'file' => [
'type' => 'string',
'format' => 'binary',
],
],
],
],
],
],
],
],
]
)]
class MediaObject
{
/**
* @var int|null
*
* @ORM\Column(type="integer")
* @ORM\GeneratedValue
* @ORM\Id
*/
protected $id;
private ?int $id = null;

/**
* @var string|null
*
* @ApiProperty(iri="http://schema.org/contentUrl")
* @Groups({"media_object_read"})
*/
public $contentUrl;
#[ApiProperty(iri: 'http://schema.org/contentUrl')]
#[Groups(['media_object:read'])]
public ?string $contentUrl = null;

/**
* @var File|null
*
* @Assert\NotNull(groups={"media_object_create"})
* @Vich\UploadableField(mapping="media_object", fileNameProperty="filePath")
*/
public $file;
#[Assert\NotNull(groups: ['media_object_create'])]
public ?File $file = null;

/**
* @var string|null
*
* @ORM\Column(nullable=true)
*/
public $filePath;
public ?string $filePath = null;

public function getId(): ?int
{
Expand All @@ -135,7 +125,7 @@ class MediaObject
}
```

## The Controller
### Creating the Controller

At this point, the entity is configured, but we still need to write the action
that handles the file upload.
Expand Down Expand Up @@ -167,7 +157,7 @@ final class CreateMediaObjectAction
}
```

## Resolving the File URL
### Resolving the File URL

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

## Making a Request to the `/media_objects` Endpoint
### Making a Request to the `/media_objects` Endpoint

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

## Accessing Your Media Objects Directly
### Accessing Your Media Objects Directly

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/|`:
```caddyfile
Expand All @@ -244,7 +234,7 @@ You will need to modify your Caddyfile to allow the above `contentUrl` to be acc
...
```

## Linking a MediaObject Resource to Another Resource
### Linking a MediaObject Resource to Another Resource

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

/**
* @ORM\Entity
* @ApiResource(iri="http://schema.org/Book")
*/
#[ApiResource(iri: 'http://schema.org/Book')]
class Book
{
// ...

/**
* @var MediaObject|null
*
* @ORM\ManyToOne(targetEntity=MediaObject::class)
* @ORM\JoinColumn(nullable=true)
* @ApiProperty(iri="http://schema.org/image")
*/
public $image;
#[ApiProperty(iri: 'http://schema.org/image')]
public ?MediaObject $image = null;

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

## Testing
### Testing

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

Expand Down Expand Up @@ -342,3 +330,152 @@ class MediaObjectTest extends ApiTestCase
}
}
```

## Uploading to an Existing Resource with its Fields

In this example, the file will be included in an existing resource (in our case: `Book`).
The file and the resource fields will be posted to the resource endpoint.

This example will use a custom `multipart/form-data` decoder to deserialize the resource instead of a custom controller.

### Configuring the Existing Resource Receiving the Uploaded File

The `Book` resource needs to be modified like this:

```php
<?php
// api/src/Entity/Book.php
namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiProperty;
use ApiPlatform\Core\Annotation\ApiResource;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\Serializer\Annotation\Groups;
use Vich\UploaderBundle\Mapping\Annotation as Vich;

/**
* @ORM\Entity
* @Vich\Uploadable
*/
#[ApiResource(
iri: 'http://schema.org/Book',
normalizationContext: ['groups' => ['book:read']],
denormalizationContext: ['groups' => ['book:write']],
collectionOperations: [
'get',
'post' => [
'input_formats' => [
'multipart' => ['multipart/form-data'],
],
],
],
)]
class Book
{
// ...

#[ApiProperty(iri: 'http://schema.org/contentUrl')]
#[Groups(['book:read'])]
public ?string $contentUrl = null;

/**
* @Vich\UploadableField(mapping="media_object", fileNameProperty="filePath")
*/
#[Groups(['book:write'])]
public ?File $file = null;

/**
* @ORM\Column(nullable=true)
*/
public ?string $filePath = null;

// ...
}
```

### Handling the Multipart Deserialization

By default, Symfony is not able to decode `multipart/form-data`-encoded data.
We need to create our own decoder to do it:

```php
<?php
// api/src/Encoder/MultipartDecoder.php

namespace App\Encoder;

use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Serializer\Encoder\DecoderInterface;

final class MultipartDecoder implements DecoderInterface
{
public const FORMAT = 'multipart';

public function __construct(private RequestStack $requestStack) {}

/**
* {@inheritdoc}
*/
public function decode(string $data, string $format, array $context = []): ?array
{
$request = $this->requestStack->getCurrentRequest();

if (!$request) {
return null;
}

return array_map(static function (string $element) {
// Multipart form values will be encoded in JSON.
$decoded = json_decode($element, true);

return \is_array($decoded) ? $decoded : $element;
}, $request->request->all()) + $request->files->all();
}

/**
* {@inheritdoc}
*/
public function supportsDecoding(string $format): bool
{
return self::FORMAT === $format;
}
}
```

If you're not using `autowiring` and `autoconfiguring`, don't forget to register the service and tag it as `serializer.encoder`.

We also need to make sure the field containing the uploaded file is not denormalized:

```php
<?php
// api/src/Serializer/UploadedFileDenormalizer.php

namespace App\Serializer;

use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;

final class UploadedFileDenormalizer implements DenormalizerInterface
{
/**
* {@inheritdoc}
*/
public function denormalize($data, string $type, string $format = null, array $context = []): UploadedFile
{
return $data;
}

/**
* {@inheritdoc}
*/
public function supportsDenormalization($data, $type, $format = null): bool
{
return $data instanceof UploadedFile;
}
}
```

If you're not using `autowiring` and `autoconfiguring`, don't forget to register the service and tag it as `serializer.normalizer`.

For resolving the file URL, you can use a custom normalizer, like shown in [the previous example](#resolving-the-file-url).