10

I'm trying to upload files with Api Platform and Vich Uploader Bundle. When I send POST request with multipart/form-data and Id of the entity to attach image file to, I get 200 response with my entity. But uploaded file doesn't uploads to destination directory and it's generated filename doesn't persists. No errors, no any clues, no idea.

Here is my code:

//vich uploader mappings
vich_uploader:
    db_driver: orm
    mappings:
        logo:
            uri_prefix: /logo
            upload_destination: '%kernel.project_dir%/public/images/logo/'
            namer: App\Infrastructure\Naming\LogoNamer
//Organization Entity
<?php

namespace App\Infrastructure\Dto;

...use

/**
 * @ORM\Entity()
 * @ApiResource(
 *     iri="https://schema.org/Organization",
 *     shortName="Place",
 *     collectionOperations={
 *          "post" = {
 *              "denormalization_context" = {
 *                  "groups"={
 *                      "organization:collection:post"
 *                  }
 *              }
 *          },
 *          "get" = {
 *              "normalization_context" = {
 *                  "groups"={
 *                      "organization:collection:get"
 *                  }
 *              }
 *          }
 *     },
 *     itemOperations={
 *          "get",
 *          "CreateOrganizationLogoAction::OPERATION_NAME" = {
 *              "groups"={"logo:post"},
 *              "method"="POST",
 *              "path"=CreateOrganizationLogoAction::OPERATION_PATH,
 *              "controller"=CreateOrganizationLogoAction::class,
 *              "deserialize"=false,
 *              "validation_groups"={"Default", "logo_create"},
 *              "openapi_context"={
 *                  "summary"="Uploads logo file to given Organization resource",
 *                  "requestBody"={
 *                      "content"={
 *                          "multipart/form-data"={
 *                              "schema"={
 *                                  "type"="object",
 *                                  "properties"={
 *                                      "logoFile"={
 *                                          "type"="string",
 *                                          "format"="binary"
 *                                      }
 *                                  }
 *                              }
 *                          }
 *                      }
 *                  }
 *              }
 *          }
 *     }
 * )
 * @Vich\Uploadable
 */
final class Organization
{
    /**
     * @Groups({"organization:collection:get"})
     * @ORM\Id
     * @ORM\Column(type="uuid", unique=true)
     * @ORM\GeneratedValue(strategy="CUSTOM")
     * @ORM\CustomIdGenerator(class=UuidGenerator::class)
     * @ApiProperty(identifier=true)
     */
    protected Uuid $id;

    /**
     * @Groups({"organization:collection:get", "organization:collection:post"})
     * @ORM\Column(type="string", length=100, unique=true)
     */
    public string $slug;

    /**
     * @ORM\Column(type="smallint")
     */
    public int $status;

    /**
     * @ApiProperty(iri="https://schema.org/name")
     * @Groups({"organization:collection:get"})
     * @ORM\Column(type="string", length=100, nullable=true)
     */
    public ?string $title = null;

    /**
     * @ApiProperty(iri="http://schema.org/logo")
     * @Groups({"organization:collection:get", "logo:post"})
     * @ORM\Column(nullable=true)
     */
    public ?string $logoPath = null;

    /**
     * @ApiProperty(iri="https://schema.org/description")
     * @ORM\Column(type="text", nullable=true)
     */
    public ?string $description = null;

    /**
     * @ApiProperty(iri="https://schema.org/disambiguatingDescription")
     * @Groups({"organization:collection:get"})
     * @ORM\Column(type="string", length=150, nullable=true)
     */
    public ?string $disambiguating_description = null;

    /**
     * @ApiProperty(iri="https://schema.org/addressCountry")
     * @ORM\Column(type="string", length=2, nullable=true)
     */
    public ?string $country = null;

    /**
     * @ApiProperty(iri="https://schema.org/addressRegion")
     * @ORM\Column(type="string", nullable=true)
     */
    public ?string $region = null;

    /**
     * @ApiProperty(iri="https://schema.org/streetAddress")
     * @ORM\Column(type="string", nullable=true)
     */
    public ?string $street = null;

    /**
     * @ApiProperty(iri="https://schema.org/telephone")
     * @ORM\Column(type="string", nullable=true)
     */
    public ?string $telephone = null;

    /**
     * @ApiProperty(iri="https://schema.org/email")
     * @ORM\Column(type="string", nullable=true)
     */
    public ?string $email = null;

    /**
     * @ApiProperty(iri="https://schema.org/contentUrl")
     * @Groups({"logo_read"})
     */
    public ?string $logoContentUrl = null;

    /**
     * @var File|null
     *
     * @Assert\NotNull(groups={"logo_create"})
     * @Vich\UploadableField(mapping="logo", fileNameProperty="logoPath")

     */
    public ?File $logoFile = null;

    public function __construct()
    {
        $this->status = OrganizationStatus::DRAFT()->getValue();
    }

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

    public function setId(Uuid $id)
    {
        $this->id = $id;
    }
}
final class CreateOrganizationLogoAction extends AbstractController
{
    const OPERATION_NAME = 'post_logo';

    const OPERATION_PATH = '/places/{id}/logo';

    private OrganizationPgRepository $repository;

    public function __construct(OrganizationPgRepository $repository)
    {
        $this->repository = $repository;
    }

    /**
     * @param Request $request
     *
     * @return EntityOrganization
     */
    public function __invoke(Request $request): EntityOrganization
    {
        $uploadedFile = $request->files->get('logoFile');
        if (!$uploadedFile) {
            throw new BadRequestHttpException('"file" is required');
        }

        $organization = $this->repository->find(Uuid::fromString($request->attributes->get('id')));
        $organization->logoFile = $uploadedFile;

        return $organization;
    }
}

I'm sending request:

curl -X POST "http://localhost:8081/api/places/0dc43a86-6402-4a45-8392-19d5e398a7ab/logo" -H "accept: application/ld+json" -H "Content-Type: multipart/form-data" -F "logoFile=@test.png;type=image/png"

... and getting response:

{
  "@context": "/api/contexts/Place",
  "@id": "/api/places/0dc43a86-6402-4a45-8392-19d5e398a7ab",
  "@type": "https://schema.org/Organization",
  "slug": "consequatur-aut-optio-corrupti-quod-sit-libero-aspernatur",
  "status": 0,
  "title": "Block LLC",
  "logoPath": "a268cde1-d93e-4d48-9f0d-177b4f89f1f8.png",
  "description": "Nisi sint ducimus consequatur dicta sint maxime. Et soluta facere in quisquam quia. Tempore quae non qui dignissimos optio rem cum illum. Eum similique vitae autem aut. Reiciendis nesciunt rerum libero in consequuntur excepturi repellendus unde. Tempore ea perferendis sunt quibusdam autem est. Similique qui illum necessitatibus velit dolores. Voluptas sapiente excepturi ad assumenda exercitationem est. Nesciunt sint sint fugiat quis blanditiis. Rerum vel sint temporibus nobis fugiat nostrum aut. Voluptatibus temporibus magnam cumque asperiores. Adipisci qui perferendis mollitia tempore accusantium aut. Possimus numquam asperiores repellendus non facilis.",
  "disambiguating_description": "Et libero temporibus ut impedit esse ipsum quam.",
  "country": "RU",
  "region": "Idaho",
  "street": "15544 Delbert Underpass",
  "telephone": "+78891211558",
  "email": "lhintz@corwin.com",
  "pictures": [],
  "social_profiles": [],
  "logoContentUrl": "/logo/a268cde1-d93e-4d48-9f0d-177b4f89f1f8.png",
  "logoFile": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD//gA7Q1JFQVRPUjog
...
...
... TgjNWnJ7YWPrMCWGxWbi57Tj58TfPQL1Hi54DRFD/FkuLcuXBKFB3TFLcuaUvpqKuYUJaLL/yV/R/+kf/Z",
  "id": "0dc43a86-6402-4a45-8392-19d5e398a7ab"
}

As you can see it's all ok. Proper organization was found. And even logoFile field was filled with uploaded picture. But uploaded file wasn't moved to destination. And logoPath contains old logo filename.

As I said no errors. Please help me to figure out where to dig.

Andrey Kryukov
  • 765
  • 1
  • 10
  • 23
  • You say: "uploaded file was not moved to destination". But to be honest, I really don't see any code where this move should take place. I suspect it would be inside the `__invoke` function. There you get the image, but you don't move it. You assign it to the `organisation`, but you don't save the updated organisation. This way your return data seems correct, but any new request will use the old setting – Maarten Veerman Jul 15 '20 at 19:34
  • @MaartenVeerman but in the documentation nothing said about manual moving or persisting. Not in Api Platform documentation, nor in Vich Uploader documentation. I think this work doing by bundles themself. When I'm trying to repeat examples from these docs all works fine. But I don't move or persist anything manualy. – Andrey Kryukov Jul 16 '20 at 03:37
  • @avkryukov did my solution fix your problem? I've had it quite some times in projects i've worked on, so I'm pretty sure this is it. But if not that would be even more interesting. – Philip Weinke Jul 25 '20 at 12:49
  • @PhilipWeinke in fact I'm already refused to use VichUploader Bundle in conjunction with ApiPlatform. But! Your solution looks like reasonable! I definitely ned to fork my project to test it. I noticed that my code didn't persist updated organizations. But with new records it worked fine. So 100% you are wright. – Andrey Kryukov Jul 25 '20 at 14:20

2 Answers2

6

I am currently working on a project which allow users to upload media files.

I have discarded the Vich bundle. Api-platform is application/ld+json oriented.

Instead, i let the user provide a base64-encoded content file (i.e a string representation with readable characters only).

The only counterpart i got is that the file size is increased by ~30% during http transfer. Honestly, it does not matter.

I suggest you to do something like the code below.

OrganizationController --use--> Organization 1 <>---> 0..1 ImageObject

The logo (note the assertion on the $encodingFormat property):

<?php

declare(strict_types=1);

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiProperty;
use ApiPlatform\Core\Annotation\ApiResource;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * An image file.
 *
 * @see http://schema.org/ImageObject Documentation on Schema.org
 *
 * @ORM\Entity
 * @ApiResource(
 *     iri="http://schema.org/ImageObject",
 *     normalizationContext={"groups" = {"imageobject:get"}}
 *     collectionOperations={"get"},
 *     itemOperations={"get"}
 * )
 */
class ImageObject
{
    /**
     * @var int|null
     *
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     * @ORM\Column(type="integer")
     * @Groups({"imageobject:get"})
     */
    private $id;

    /**
     * @var string|null the name of the item
     *
     * @ORM\Column(type="text", nullable=true)
     * @ApiProperty(iri="http://schema.org/name")
     * @Groups({"imageobject:get"})
     */
    private $name;

    /**
     * @var string|null actual bytes of the media object, for example the image file or video file
     *
     * @ORM\Column(type="text", nullable=true)
     * @ApiProperty(iri="http://schema.org/contentUrl")
     * @Groups({"imageobject:get"})
     */
    private $contentUrl;

    /**
     * @var string|null mp3, mpeg4, etc
     *
     * @Assert\Regex("#^image/.*$#", message="This is not an image, this is a {{ value }} file.")
     * @ORM\Column(type="text", nullable=true)
     * @ApiProperty(iri="http://schema.org/encodingFormat")
     * @Groups({"imageobject:get"})
     */
    private $encodingFormat;
    
    // getters and setters, nothing specific here

Your stripped Organization class, which declare the OrganizationController:

<?php

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiResource;
use App\Repository\OrganizationRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use App\Controller\OrganizationController;

/**
 * @ApiResource(
 *     normalizationContext={
            "groups" = {"organization:get"}
 *     },
 *     denormalizationContext={
            "groups" = {"organization:post"}
 *     },
 *     collectionOperations={
            "get",
 *          "post" = {
 *              "controller" = OrganizationController::class
 *          }
 *     }
 * )
 * @ORM\Entity(repositoryClass=OrganizationRepository::class)
 */
class Organization
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     * @Groups({"organization:get"})
     */
    private $id;

    /**
     * @var string
     * @ORM\Column(type="string", length=100, unique=true)
     * @Groups({"organization:get", "organization:post"})
     */
    private $slug;

    /**
     * @var null|ImageObject
     * @Assert\Valid()
     * @ORM\OneToOne(targetEntity=ImageObject::class, cascade={"persist", "remove"})
     * @Groups({"organization:get"})
     */
    private $logo;

    /**
     * @var string the logo BLOB, base64-encoded, without line separators.
     * @Groups({"organization:post"})
     */
    private $b64LogoContent;

    // getters and setters, nothing specific here...

}

Note the serialization groups of both $logo and $b64LogoContent properties.

Then the controller (action class), in order to decode, assign and write the logo content.

<?php


namespace App\Controller;

use App\Entity\ImageObject;
use App\Entity\Organization;
use finfo;

/**
 * Handle the base64-encoded logo content.
 */
class OrganizationController
{
    public function __invoke(Organization $data)
    {
        $b64LogoContent = $data->getB64LogoContent();
        if (! empty($b64LogoContent)) {
            $logo = $this->buildAndWriteLogo($b64LogoContent);
            $data->setLogo($logo);
        }
        return $data;
    }

    private function buildAndWriteLogo(string $b64LogoContent): ImageObject
    {
        $logo = new ImageObject();
        $content = str_replace("\n", "", base64_decode($b64LogoContent));
        $mimeType = (new finfo())->buffer($content, FILEINFO_MIME_TYPE);
        $autoGeneratedId = $this->createFileName($content, $mimeType); // Or anything to generate an ID, like md5sum
        $logo->setName($autoGeneratedId);
        $logo->setContentUrl("/public/images/logo/$autoGeneratedId");
        $logo->setEncodingFormat($mimeType);
        // check the directory permissions!
        // writing the file should be done after data validation
        file_put_contents("images/logo/$autoGeneratedId", $content);
        return $logo;
    }

    private function createFileName(string $content, string $mimeType): string
    {
        if (strpos($mimeType, "image/") === 0) {
            $extension = explode('/', $mimeType)[1];
        } else {
            $extension = "txt";
        }
        return time() . ".$extension";
    }
}

It checks whether the supplied logo is a "tiny image" with @Assert annotations of the ImageObject class (encodingFormat, width, height etc.), they are triggered by the @Assert\Valid annotation of the Organization::$logo property.

With that, you can create an organization with its logo by sending a single HTTP POST /organizations request.

rugolinifr
  • 1,211
  • 1
  • 5
  • 11
6

The VichUploaderBundle does the upload handling in a doctrine event listener using the prePersist and preUpdate hooks. The problem in your case is, that - from doctrines point of view - no persistent property has changed. Since there is no change, the upload listener won't be called.

A simple workaround is to always change a persistent property when a file was uploaded. I added updatedAt to your entity and the method updateLogo to keep the required change of logoFile and updatedAt together.

final class Organization
{
    (...)

    /**
     * @ApiProperty(iri="http://schema.org/logo")
     * @Groups({"organization:collection:get", "logo:post"})
     * @ORM\Column(nullable=true)
     */
    public ?string $logoPath = null;

    /**
     * @ORM\Column(type="datetime")
     */
    private ?DateTime $updatedAt = null;

    /**
     * @var File|null
     *
     * @Assert\NotNull(groups={"logo_create"})
     * @Vich\UploadableField(mapping="logo", fileNameProperty="logoPath")
     */
    private ?File $logoFile = null;
    
    (...)

    public function updateLogo(File $logo): void
    {
       $this->logoFile  = $logo;
       $this->updatedAt = new DateTime();
    }
}
final class CreateOrganizationLogoAction extends AbstractController
{
    (...)

    /**
     * @param Request $request
     *
     * @return EntityOrganization
     */
    public function __invoke(Request $request): EntityOrganization
    {
        $uploadedFile = $request->files->get('logoFile');
        if (!$uploadedFile) {
            throw new BadRequestHttpException('"file" is required');
        }

        $organization = $this->repository->find(Uuid::fromString($request->attributes->get('id')));
        $organization->updateLogo($uploadedFile);

        return $organization;
    }
}
Philip Weinke
  • 1,804
  • 1
  • 8
  • 13
  • Thank you! It's a realy good idea! Need to test it. – Andrey Kryukov Jul 25 '20 at 14:22
  • By the way I found that in _invoke method no need to find entity using repository. You can just autowire it like so: public function __invoke(Request $request, EntityOrganization $data): EntityOrganization $data will contain proper entity. But somehow it necessarily name this variable $data. Or it won't work. – Andrey Kryukov Jul 25 '20 at 14:24
  • Can you please share a thought on validation of image upload...?? – Vishal Tanna Dec 12 '20 at 03:59
  • @VishalTanna I know this is very late but you can use Assert. For example `@Assert\File(maxSize = "1M",maxSizeMessage = "Maximum size allowed : {{ limit }} {{ suffix }}.",mimeTypes = {"image/png", "image/jpg", "image/jpeg"},mimeTypesMessage = "Allowed formats : png, jpg, jpeg.",) – user2220551 Jan 31 '22 at 15:50