Handling File Upload

As common a problem as it may seem, handling file upload requires a custom implementation in your app. This page will guide you in handling file upload in your API, with the help of VichUploaderBundle. It is recommended you read the documentation of VichUploaderBundle before proceeding. It will help you get a grasp on how the bundle works, and why we use it.

よくある問題に思えるかもしれませんが、ファイルのアップロードを処理するには、アプリでカスタム実装が必要です。このページでは、VichUploaderBundle を使用して、API でファイルのアップロードを処理する方法について説明します。続行する前に、VichUploaderBundle のドキュメントを読むことをお勧めします。バンドルがどのように機能するか、およびバンドルを使用する理由を理解するのに役立ちます。

Installing VichUploaderBundle

Install the bundle with the help of Composer:

Composer を使用してバンドルをインストールします。

docker compose exec php \
    composer require vich/uploader-bundle

This will create a new configuration file that you will need to slightly change to make it look like this.

これにより、このように見えるように少し変更する必要がある新しい構成ファイルが作成されます。

# api/config/packages/vich_uploader.yaml
vich_uploader:
    db_driver: orm
    metadata:
        type: attribute
    mappings:
        media_object:
            uri_prefix: /media
            upload_destination: '%kernel.project_dir%/public/media'
            # Will rename uploaded files using a uniqueid as a prefix.
            namer: Vich\UploaderBundle\Naming\OrignameNamer

Uploading to a Dedicated Resource

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).

この例では、MediaObject API リソースを作成します。ファイルをこのリソース エンドポイントに投稿し、新しく作成したリソースを別のリソース (この場合は 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.

この例では、カスタム コントローラーを使用してファイルを受信します。2 番目の例では、代わりにカスタム マルチパート/フォーム データ デコーダーを使用してリソースを逆シリアル化します。

Note: Uploading files won't work in PUT or PATCH requests, you must use POST method to upload files. See the related issue on Symfony and the related bug in PHP talking about this behavior.

注: ファイルのアップロードは、PUT または PATCH リクエストでは機能しません。ファイルをアップロードするには、POST メソッドを使用する必要があります。この動作については、Symfony の関連する問題と PHP の関連するバグを参照してください。

Configuring the Resource Receiving the Uploaded File

The MediaObject resource is implemented like this:

MediaObject リソースは次のように実装されます。

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

use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use App\Controller\CreateMediaObjectAction;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Vich\UploaderBundle\Mapping\Annotation as Vich;

#[Vich\Uploadable]
#[ORM\Entity]
#[ApiResource(
    normalizationContext: ['groups' => ['media_object:read']], 
    types: ['https://schema.org/MediaObject'],
    operations: [
        new Get(),
        new GetCollection(),
        new Post(
            controller: CreateMediaObjectAction::class, 
            deserialize: false, 
            validationContext: ['groups' => ['Default', 'media_object_create']], 
            openapiContext: [
                'requestBody' => [
                    'content' => [
                        'multipart/form-data' => [
                            'schema' => [
                                'type' => 'object', 
                                'properties' => [
                                    'file' => [
                                        'type' => 'string', 
                                        'format' => 'binary'
                                    ]
                                ]
                            ]
                        ]
                    ]
                ]
            ]
        )
    ]
)]
class MediaObject
{
    #[ORM\Id, ORM\Column, ORM\GeneratedValue]
    private ?int $id = null;

    #[ApiProperty(types: ['https://schema.org/contentUrl'])]
    #[Groups(['media_object:read'])]
    public ?string $contentUrl = null;

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

    #[ORM\Column(nullable: true)] 
    public ?string $filePath = null;

    public function getId(): ?int
    {
        return $this->id;
    }
}

Creating the Controller

At this point, the entity is configured, but we still need to write the action that handles the file upload.

この時点で、エンティティは構成されていますが、ファイルのアップロードを処理するアクションを記述する必要があります。

<?php
// api/src/Controller/CreateMediaObjectAction.php

namespace App\Controller;

use App\Entity\MediaObject;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;

#[AsController]
final class CreateMediaObjectAction extends AbstractController
{
    public function __invoke(Request $request): MediaObject
    {
        $uploadedFile = $request->files->get('file');
        if (!$uploadedFile) {
            throw new BadRequestHttpException('"file" is required');
        }

        $mediaObject = new MediaObject();
        $mediaObject->file = $uploadedFile;

        return $mediaObject;
    }
}

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.

ファイルが保存されているファイル システムのプレーン ファイル パスを返すことは、クライアントにとっては役に立ちません。

A normalizer could be used to set the contentUrl property:

ノーマライザーを使用して contentUrl プロパティを設定できます。

<?php
// api/src/Serializer/MediaObjectNormalizer.php

namespace App\Serializer;

use App\Entity\MediaObject;
use Symfony\Component\Serializer\Normalizer\ContextAwareNormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Vich\UploaderBundle\Storage\StorageInterface;

final class MediaObjectNormalizer implements ContextAwareNormalizerInterface, NormalizerAwareInterface
{
    use NormalizerAwareTrait;

    private const ALREADY_CALLED = 'MEDIA_OBJECT_NORMALIZER_ALREADY_CALLED';

    public function __construct(private StorageInterface $storage)
    {
    }

    public function normalize($object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
    {
        $context[self::ALREADY_CALLED] = true;

        $object->contentUrl = $this->storage->resolveUri($object, 'file');

        return $this->normalizer->normalize($object, $format, $context);
    }

    public function supportsNormalization($data, ?string $format = null, array $context = []): bool
    {
        if (isset($context[self::ALREADY_CALLED])) {
            return false;
        }

        return $data instanceof MediaObject;
    }
}

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 not JSON data. You will need to format your request accordingly. After posting your data, you will get a response looking like this:

/media_objects エンドポイントは、ファイルを含む POST リクエストを受け取る準備ができました。このエンドポイントは、標準の multipart/form-data-encoded データを受け入れますが、JSON データは受け入れません。それに応じてリクエストをフォーマットする必要があります。データを投稿すると、次のような応答が返されます。

{
  "@type": "https://schema.org/MediaObject",
  "@id": "/media_objects/<id>",
  "contentUrl": "<url>"
}

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/|:

上記の contentUrl に直接アクセスできるようにするには、Caddyfile を変更する必要があります。上記の VichUploaderBundle の構成に従った場合、それは api/public/media になります。フォルダーをパス一致のリストに追加します。 |^/メディア/|:

...
# Matches requests for HTML documents, for static files and for Next.js files,
# except for known API paths and paths with extensions handled by API Platform
@pwa expression `(
        {header.Accept}.matches("\\btext/html\\b")
        && !{path}.matches("(?i)(?:^/docs|^/graphql|^/bundles/|^/media/|^/_profiler|^/_wdt|\\.(?:json|html$|csv$|ya?ml$|xml$))")
...

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.

ここで Book リソースを更新する必要があります。これにより、MediaObject をリンクして本の表紙として機能させることができます。

We first need to edit our Book resource, and add a new property called image.

最初に Book リソースを編集し、image という新しいプロパティを追加する必要があります。

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

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

#[ORM\Entity]
#[ApiResource(types: ['https://schema.org/Book'])]
class Book
{
    // ...

    #[ORM\ManyToOne(targetEntity: MediaObject::class)]
    #[ORM\JoinColumn(nullable: true)]
    #[ApiProperty(types: ['https://schema.org/image'])]
    public ?MediaObject $image = null;

    // ...
}

By sending a POST request to create a new book, linked with the previously uploaded cover, you can have a nice illustrated book record!

前回アップロードした表紙と連動させて新規作成のPOSTリクエストを送ることで素敵な図鑑レコードが出来上がります!

POST /books

投稿 /books

{
  "name": "The name",
  "image": "/media_objects/<id>"
}

Voilà! You can now send files to your API, and link them to any other resource in your app.

ほら!ファイルを API に送信し、アプリ内の他のリソースにリンクできるようになりました。

Testing

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

ApiTestCase でアップロードをテストするには、次のようにメソッドを記述できます。

<?php
// tests/MediaObjectTest.php

namespace App\Tests;

use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Entity\MediaObject;
use Hautelook\AliceBundle\PhpUnit\RefreshDatabaseTrait;
use Symfony\Component\HttpFoundation\File\UploadedFile;

class MediaObjectTest extends ApiTestCase
{
    use RefreshDatabaseTrait;

    public function testCreateAMediaObject(): void
    {
        $file = new UploadedFile('fixtures/files/image.png', 'image.png');
        $client = self::createClient();

        $client->request('POST', '/media_objects', [
            'headers' => ['Content-Type' => 'multipart/form-data'],
            'extra' => [
                // If you have additional fields in your MediaObject entity, use the parameters.
                'parameters' => [
                    'title' => 'My file uploaded',
                ],
                'files' => [
                    'file' => $file,
                ],
            ]
        ]);
        $this->assertResponseIsSuccessful();
        $this->assertMatchesResourceItemJsonSchema(MediaObject::class);
        $this->assertJsonContains([
            'title' => 'My file uploaded',
        ]);
    }
}

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.

この例では、ファイルは既存のリソース (この場合は Book) に含まれます。ファイルとリソース フィールドはリソース エンドポイントにポストされます。

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

この例では、カスタム コントローラーの代わりに、カスタム multipart/form-data デコーダーを使用してリソースを逆シリアル化します。

Configuring the Existing Resource Receiving the Uploaded File

The Book resource needs to be modified like this:

Book リソースは次のように変更する必要があります。

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

use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
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;

/**
 * @Vich\Uploadable
 */
#[ORM\Entity]
#[ApiResource(
    normalizationContext: ['groups' => ['book:read']], 
    denormalizationContext: ['groups' => ['book:write']], 
    types: ['https://schema.org/Book'],
    operations: [
        new GetCollection(),
        new Post(inputFormats: ['multipart' => ['multipart/form-data']])
    ]
)]
class Book
{
    // ...

    #[ApiProperty(types: ['https://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:

デフォルトでは、Symfony は multipart/form-data-encoded データをデコードできません。これを行うには、独自のデコーダーを作成する必要があります。

<?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.

自動配線と自動構成を使用していない場合は、サービスを登録して、serializer.encoder としてタグ付けすることを忘れないでください。

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

また、アップロードされたファイルを含むフィールドが非正規化されていないことを確認する必要があります。

<?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.

自動配線と自動構成を使用していない場合は、サービスを登録して、serializer.normalizer としてタグ付けすることを忘れないでください。

For resolving the file URL, you can use a custom normalizer, like shown in the previous example.

ファイル URL を解決するには、前の例で示したように、カスタム ノーマライザーを使用できます。