API Platformでファイルアップロード

一見よくある問題ですが、ファイルのアップロードを処理するには、アプリでのカスタム実装が必要です。このページでは、VichUploaderBundle を使用して、API でファイルのアップロードを処理する方法について説明します。

続行する前に、VichUploaderBundle のドキュメントを読むことをお勧めします。バンドルがどのように機能するか、およびバンドルを使用する理由を理解するのに役立ちます。

 composer require vich/uploader-bundle

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

# 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

専用リソースへのアップロード

この例では、MediaObject API リソースを作成します。ファイルをこのリソース エンドポイントに投稿し、新しく作成したリソースを別のリソース (この場合は Book) にリンクします。

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

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

アップロードされたファイルを受け取るリソースの構成 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

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

<?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;
    }
}

ファイル URL の解決

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

ノーマライザーを使用して 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;
    }
}
/media_objects エンドポイントへのリクエストの作成

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

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

メディア オブジェクトに直接アクセスする

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

MediaObject リソースを別のリソースにリンクする

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

最初に 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;
    
    // ...
}

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

POST /books

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

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

Testing

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',
        ]);
    }
}

フィールドを含む既存のリソースへのアップロード

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

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

アップロードされたファイルを受け取る既存のリソースの構成

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

デフォルトでは、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;
    }
}

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

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

<?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;
    }
}

自動配線と自動構成を使用していない場合は、サービスを登録して、serializer.normalizer としてタグ付けすることを忘れないでください。 ファイル URL を解決するには、前の例で示したように、カスタム ノーマライザーを使用できます。