Search code examples
phpsymfonydoctrine-ormfosrestbundle

File not found exception on Symfony upload


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 DemoBundle\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;
use ApiBundle\Entity\BaseEntity;
use ApiBundle\Entity\Client;
use JMS\Serializer\Annotation as Serializer;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\Validator\Constraints;

/**
 * Class BusinessClient
 * @package DemoBundle\Entity
 * @ORM\Entity(repositoryClass="DemoBundle\Repository\ClientRepository")
 * @ORM\Table(name="business_client")
 * @Serializer\ExclusionPolicy("all")
 * @Serializer\AccessorOrder("alphabetical")
 */
class BusinessClient extends BaseEntity
{
    /**
     * @Constraints\NotBlank()
     * @ORM\ManyToOne(targetEntity="ApiBundle\Entity\Client", fetch="EAGER")
     * @ORM\JoinColumn(name="client_id", referencedColumnName="oauth2_client_id", nullable=false)
     */
    public $client;

    /**
     * @Constraints\NotBlank()
     * @ORM\Column(type="string", length=255, nullable=false)
     * @Serializer\Expose
     */
    protected $name;

    /**
     * @Constraints\Image(minWidth=100, minHeight=100)
     * @ORM\Column(type="text", nullable=true)
     */
    protected $logo;

    /**
     * One Business may have many brands
     * @ORM\OneToMany(targetEntity="DemoBundle\Entity\Brand", mappedBy="business")
     * @Serializer\Expose
     */
    protected $brands;

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

    /**
     * Set the links property for the resource response
     *
     * @Serializer\VirtualProperty(name="_links")
     * @Serializer\SerializedName("_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
     *
     * @Serializer\Expose
     * @Serializer\VirtualProperty(name="logo")
     * @Serializer\SerializedName("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 DemoBundle\Services;


use Symfony\Component\HttpFoundation\File\UploadedFile;

/**
 * Class FileUploader
 * @package DemoBundle\Services
 */
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
  DemoBundle\Services\FileUploader:
    arguments:
      $uploadDir: '%logo_upload_dir%'

And last the controller:

<?php

namespace DemoBundle\Controller;

use ApiBundle\Exception\HttpException;
use DemoBundle\Entity\BusinessClient;
use DemoBundle\Services\FileUploader;
use FOS\RestBundle\Controller\Annotations as REST;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Swagger\Annotations as SWG;
use Symfony\Component\Validator\Constraints\ImageValidator;
use Symfony\Component\Validator\ConstraintValidatorInterface;
use Symfony\Component\Validator\ConstraintViolationInterface;


/**
 * Class BusinessClientController
 * @package DemoBundle\Controller
 */
class BusinessClientController extends BaseController
{

    /**
     * Create a new business entity and persist it in database
     *
     * @REST\Post("/clients")
     * @SWG\Tag(name="business_clients")
     * @SWG\Response(
     *     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
     *
     * @REST\Get("/clients/{id}", requirements={"id" = "\d+"})
     * @param $id
     * @return Response
     * @SWG\Tag(name="business_clients")
     * @SWG\Response(
     *     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.


Solution

  • 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
         *
         * @Constraints\Image(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...