Api platform handling fille uploads

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

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.