File not found exception on Symfony upload

Tags: , , ,



I’m using Symfony 3.4 to work on a simple REST API microservice. There are not much resources to be found when working with HTTP APIs and file uploads. I’m following some of the instructions from the documentation but I found a wall.

What I want to do is to store the relative path to an uploaded file on an entity field, but it seems like the validation expects the field to be a full path.

Here’s some of my code:

<?php
// BusinessClient.php
namespace DemoBundleEntity;

use DoctrineCommonCollectionsArrayCollection;
use DoctrineORMMapping as ORM;
use ApiBundleEntityBaseEntity;
use ApiBundleEntityClient;
use JMSSerializerAnnotation as Serializer;
use SymfonyComponentHttpFoundationFileFile;
use SymfonyComponentHttpFoundationFileUploadedFile;
use SymfonyComponentValidatorConstraints;

/**
 * Class BusinessClient
 * @package DemoBundleEntity
 * @ORMEntity(repositoryClass="DemoBundleRepositoryClientRepository")
 * @ORMTable(name="business_client")
 * @SerializerExclusionPolicy("all")
 * @SerializerAccessorOrder("alphabetical")
 */
class BusinessClient extends BaseEntity
{
    /**
     * @ConstraintsNotBlank()
     * @ORMManyToOne(targetEntity="ApiBundleEntityClient", fetch="EAGER")
     * @ORMJoinColumn(name="client_id", referencedColumnName="oauth2_client_id", nullable=false)
     */
    public $client;

    /**
     * @ConstraintsNotBlank()
     * @ORMColumn(type="string", length=255, nullable=false)
     * @SerializerExpose
     */
    protected $name;

    /**
     * @ConstraintsImage(minWidth=100, minHeight=100)
     * @ORMColumn(type="text", nullable=true)
     */
    protected $logo;

    /**
     * One Business may have many brands
     * @ORMOneToMany(targetEntity="DemoBundleEntityBrand", mappedBy="business")
     * @SerializerExpose
     */
    protected $brands;

    /**
     * BusinessClient constructor.
     */
    public function __construct()
    {
        $this->brands = new ArrayCollection();
    }

    /**
     * Set the links property for the resource response
     *
     * @SerializerVirtualProperty(name="_links")
     * @SerializerSerializedName("_links")
     */
    public function getLinks()
    {
        return [
            "self" => "/clients/{$this->getId()}",
            "brands" => "/clients/{$this->getId()}/brands"
        ];
    }

    /**
     * Get the name of the business client
     *
     * @return string
     */
    public function getName()
    {
        return $this->name;
    }

    /**
     * Set the name of the business client
     *
     * @param string $name
     */
    public function setName($name): void
    {
        $this->name = $name;
    }

    /**
     * Get the logo
     *
     * @SerializerExpose
     * @SerializerVirtualProperty(name="logo")
     * @SerializerSerializedName("logo")
     */
    public function getLogo()
    {
        return $this->logo;
    }

    /**
     * Set the logo field
     *
     * @param string|File|UploadedFile $logo
     */
    public function setLogo($logo): void
    {
        $this->logo = $logo;
    }

    /**
     * Get the client property
     *
     * @return Client
     */
    public function getClient()
    {
        return $this->client;
    }

    /**
     * Set the client property
     *
     * @param Client $client
     */
    public function setClient($client): void
    {
        $this->client = $client;
    }
}

Uploader Service:

<?php

namespace DemoBundleServices;


use SymfonyComponentHttpFoundationFileUploadedFile;

/**
 * Class FileUploader
 * @package DemoBundleServices
 */
class FileUploader
{
    /** @var string $uploadDir The directory where the files will be uploaded */
    private $uploadDir;

    /**
     * FileUploader constructor.
     * @param $uploadDir
     */
    public function __construct($uploadDir)
    {
        $this->uploadDir = $uploadDir;
    }

    /**
     * Upload a file to the specified upload dir
     * @param UploadedFile $file File to be uploaded
     * @return string The unique filename generated
     */
    public function upload(UploadedFile $file)
    {
        $fileName = md5(uniqid()).'.'.$file->guessExtension();

        $file->move($this->getTargetDirectory(), $fileName);

        return $fileName;
    }

    /**
     * Get the base dir for the upload files
     * @return string
     */
    public function getTargetDirectory()
    {
        return $this->uploadDir;
    }
}

I’ve registered the service:

services:
  # ...
  public: false
  DemoBundleServicesFileUploader:
    arguments:
      $uploadDir: '%logo_upload_dir%'

And last the controller:

<?php

namespace DemoBundleController;

use ApiBundleExceptionHttpException;
use DemoBundleEntityBusinessClient;
use DemoBundleServicesFileUploader;
use FOSRestBundleControllerAnnotations as REST;
use PsrLogLoggerInterface;
use SymfonyComponentHttpFoundationFileFile;
use SymfonyComponentHttpFoundationRequest;
use SymfonyComponentHttpFoundationResponse;
use SwaggerAnnotations as SWG;
use SymfonyComponentValidatorConstraintsImageValidator;
use SymfonyComponentValidatorConstraintValidatorInterface;
use SymfonyComponentValidatorConstraintViolationInterface;


/**
 * Class BusinessClientController
 * @package DemoBundleController
 */
class BusinessClientController extends BaseController
{

    /**
     * Create a new business entity and persist it in database
     *
     * @RESTPost("/clients")
     * @SWGTag(name="business_clients")
     * @SWGResponse(
     *     response="201",
     *     description="Create a business client and return it's data"
     * )
     * @param Request $request
     * @param FileUploader $uploader
     * @return Response
     * @throws HttpException
     */
    public function createAction(Request $request, FileUploader $uploader, LoggerInterface $logger)
    {
        $entityManager = $this->getDoctrine()->getManager();
        $oauthClient = $this->getOauthClient();

        $data = $request->request->all();
        $client = new BusinessClient();
        $client->setName($data["name"]);
        $client->setClient($oauthClient);

        $file = $request->files->get('logo');

        if (!is_null($file)) {
            $fileName = $uploader->upload($file);
            $client->setLogo($fileName);
        }

        $errors = $this->validate($client);
        if (count($errors) > 0) {
            $err = [];
            /** @var ConstraintViolationInterface $error */
            foreach ($errors as $error) {
                $err[] = [
                    "message" => $error->getMessage(),
                    "value" => $error->getInvalidValue(),
                    "params" => $error->getParameters()
                ];
            }
            throw HttpException::badRequest($err);
        }

        $entityManager->persist($client);
        $entityManager->flush();

        $r = new Response();

        $r->setContent($this->serialize($client));
        $r->setStatusCode(201);
        $r->headers->set('Content-type', 'application/json');

        return $r;
    }

    /**
     * Get data for a single business client
     *
     * @RESTGet("/clients/{id}", requirements={"id" = "d+"})
     * @param $id
     * @return Response
     * @SWGTag(name="business_clients")
     * @SWGResponse(
     *     response="200",
     *     description="Get data for a single business client"
     * )
     */
    public function getClientAction($id)
    {
        $object = $this->getDoctrine()->getRepository(BusinessClient::class)
            ->find($id);
        $j = new Response($this->serialize($object));
        return $j;
    }
}

When I try to set the logo as a file basename string the request will fail. with error that the file (basename) is not found. This makes sense in a way.

If otherwise I try to set not a string but a File with valid path to the newly uploaded file the request will succeed, but the field in the table will be replaced with a full system path. The same happens when I put a valid system path instead of a file.

<?php

// Controller 
.....


// This works
if (!is_null($file)) {
    $fileName = $uploader->upload($file);
    $client->setLogo($this->getParameter("logo_upload_dir")."/$fileName");
}

Parameter for the upload dir:

parameters:
  logo_upload_dir: '%kernel.project_dir%/web/uploads/logos'

I’m not using any forms as this is a third party API and I’m mainly using the request objects directly to handle the data. Most of the documentations used Forms to handle this. Also all my responses are in JSON.

I’d appreciate any help on this. Otherwise I’ll have to store the full path and that in not a good idea and very impractical.

Thanks in advance.

Answer

Here is a thought on this: Instead of validating the property which your plan to be a relative path to an image, validate the method. Something like this maybe:

class BusinessClient extends BaseEntity
{
    public static $basePath;

    // ....
    /**
     * Get the logo
     *
     * @ConstraintsImage(minWidth=100, minHeight=100)
     */
    public function getAbsolutePathLogo()
    {
        return self::$basePath . '/' . $this->logo;
    }

So, remove the validation from your logo member, add a new method (I named it getAbsolutePathLogo buy you can choose anything) and set up validation on top of it.

This way, your logo will be persisted as relative path and validation should work. However, the challenge now is to determine the right moment to set that static $basePath. In reality, this one does not even need to be a class static member, but could be something global:

return MyGlobalPath::IMAGE_PATH . '/' . $this->logo;

Does this make sense?

Hope it helps a bit…



Source: stackoverflow