File POST

This video is available to view for members only.

Click here to Join!

Already a member?

Login


In this video we are going to cover the POST method for File resources. This will cover the differences between a POST for any of the other resources, which is largely around how to handle a file upload as part of a form submission.

Up until now we have only covered how to send in data in JSON format to our API endpoints, but now we have to think about how a file might be sent in as well.

As ever with problems like this, there are multiple ways of addressing the problem.

The best piece of advice I can give you, I believe, is: try not to overthink this problem.

If you have ever done a form with a file upload, you have already solved this problem! It's no different. Just because this is an API without a visible form front end doesn't mean this works any differently to an 'old fashioned' form with a file / upload input:

<!-- The data encoding type, enctype, MUST be specified as below -->
<form enctype="multipart/form-data" action="__URL__" method="POST">
    <!-- MAX_FILE_SIZE must precede the file input field -->
    <input type="hidden" name="MAX_FILE_SIZE" value="30000" />
    <!-- Name of input element determines name in $_FILES array -->
    Send this file: <input name="userfile" type="file" />
    <input type="submit" value="Send File" />
</form>

The above example is taken from the php.net documentation. There's an interesting piece in the example above which, if you only ever work with 'text'y inputs, may catch you out inside your Symfony application. This is the $_FILES section. We will cover this more shortly.

Let's take a quick look at the scenario we are covering here:

# src/AppBundle/Features/file.feature

    Scenario: User can add a new File
      When I send a multipart "POST" request to "/accounts/a1/files" with form data:
        | name            | filePath        |
        | a new file name | Image/pk140.jpg |
      Then the response code should be 201
       And the response header "Content-Type" should be equal to "application/json; charset=utf-8"
       And the I follow the link in the Location response header
       And the response should contain json:
        """
        {
          "originalFileName": "pk140.jpg",
          "guessedExtension": "jpg",
          "displayedFileName": "pk140.jpg",
          "fileSize": 8053
        }
        """

This is largely the same as any of the other scenarios we have covered so far, with one exception - the first when.

The code behind for this step is as follows:

    /**
     * @When /^(?:I )?send a multipart "([A-Z]+)" request to "([^"]+)" with form data:$/
     */
    public function iSendAMultipartRequestToWithFormData($method, $url, TableNode $post)
    {
        $url = $this->prepareUrl($url);

        $this->request = $this->getClient()->createRequest($method, $url);

        $data = $post->getColumnsHash()[0];

        $hasFile = false;

        if (array_key_exists('filePath', $data)) {
            $filePath = $this->dummyDataPath . $data['filePath'];
            unset($data['filePath']);
            $hasFile = true;
        }


        /** @var \GuzzleHttp\Post\PostBodyInterface $requestBody */
        $requestBody = $this->request->getBody();
        foreach ($data as $key => $value) {
            $requestBody->setField($key, $value);
        }


        if ($hasFile) {
            $file = fopen($filePath, 'rb');
            $postFile = new PostFile('uploadedFile', $file);
            $requestBody->addFile($postFile);
        }


        if (!empty($this->headers)) {
            $this->request->addHeaders($this->headers);
        }
        $this->request->setHeader('Content-Type', 'multipart/form-data');

        $this->sendRequest();
    }

The request is sent with the Content-Type of multipart/form-data, just like in a standard file upload.

What's more interesting is the way we use Guzzle to attach a file to the request. If we weren't using Guzzle, the implementation here would be different. This heavily ties out whole testing setup to Guzzle. Honestly, I am fine with this. Worrying about abstracting out the way we make requests inside our test suite is waaay beyond a concern for me.

Once Guzzle (or a real client) sends in a request, we need some way of handling that request. Enter our postAction:

// src/AppBundle/Controller/FilesController.php

    public function postAction(Request $request, $accountId)
    {
        $this->getFileHandler()->setAccount(
            $this->getAccountHandler()->get($accountId)
        );

        $parameters = array_replace_recursive(
            $request->request->all(),
            $request->files->all()
        );

        try {
            $file = $this->getFileHandler()->post($parameters);
        } catch (InvalidFormException $e) {

            return $e->getForm();
        }

        $routeOptions = [
            'accountId'  => $accountId,
            'fileId'     => $file->getId(),
            '_format'    => $request->get('_format'),
        ];

        return $this->routeRedirectView('get_accounts_files', $routeOptions, Response::HTTP_CREATED);
    }

Largely this looks very similar to any of the other controller actions in the system.

There is one difference though - the $parameters variable. What on Earth is going on there?

Well, as mentioned earlier, most of the time when working with forms, we aren't handling file submissions. At least, I don't. Most forms are data in some form, but not file data.

Couple this with the fact that if you work with Symfony's $request object frequently, you may (almost) entirely forget about the underlying PHP $_FILES global. This leads to a situation where you may be left scratching your head as to why $request->request->all() doesn't contain your uploaded file data. Shame face

Hopefully you will remember that Symfony's $request object is a wrapper around all those helpful global variables.

This means we can easily get access to the uploaded file(s) via $request->files.

The problem is, our handler implementation expects these $parameters to come ready and raring to go. Therefore, before sending the $parameters off to the handler, I decided to merge the two arrays:

    $parameters = array_replace_recursive(
        $request->request->all(),
        $request->files->all()
    );

Moving to the FileHandler, we can see how this comes together:

// src/AppBundle/Handler/FileHandler.php

    /**
     * @param  array                 $parameters
     * @param  array                 $options
     * @return FileInterface
     */
    public function post(array $parameters, array $options = [])
    {
        $account = $this->getAccount();

        $options = array_replace_recursive([
            'validation_groups' => ['post'],
            'has_file'          => true,
        ], $options);

        $fileDTO = $this->formHandler->handle(
            new FileDTO(),
            $parameters,
            Request::METHOD_POST,
            $options
        ); /** @var $fileDTO FileDTO */

        $file = $this->factory->createFromUploadedFile($fileDTO->getUploadedFile());

        $fileContents = $this->uploadFilesystem->getFileContentsFromPath($fileDTO->getUploadedFile()->getFilePath());
        $this->filesystem->put($file->getInternalFileName(), $fileContents);

        $account->addFile($file);

        $this->repository->save($file);

        return $file;
    }

We see some things we have already covered (the way we get to a result for $fileDTO, saving to the repository), and also some new things happening here.

    $options = array_replace_recursive([
        'validation_groups' => ['post'],
        'has_file'          => true,
    ], $options);

We can create any form options we like. We'll see how validation_groups and has_file are used shortly. For now, again, this is a way to merge any $options that are passed in to this method with the defaults. The defaults - for clarity - being set right here in the post method:

    'validation_groups' => ['post'],
    'has_file'          => true,

The $parameters (aka submitted form data), and $options are then sent to the form, and all the form workings that we have covered in previous videos do their magic. It's worth a quick look at the FileType form type though:

// * snip *
use Symfony\Component\Form\Extension\Core\Type\FileType as CoreFileType;

class FileType extends AbstractType
{
    /**
     * @param FormBuilderInterface $builder
     * @param array $options
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('name', TextType::class, [
                'required' => false,
            ])
        ;

        if ($options['has_file']) {
            $builder->add('uploadedFile', CoreFileType::class, [
                'multiple' => false,
            ]);
        }
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => 'AppBundle\DTO\FileDTO',
            'has_file'   => true,
        ]);
    }

has_file is an option I created for my own purposes, so a default value has to be set or Symfony's form component will have a meltdown.

validation_groups is a default option though, so that's why it's not explicitly declared in configureOptions.

Then, inside buildForm we can check whether the $options['has_file'] evaluates to true or false and include or skip the inclusion of the uploadedFile form field. Remember we set has_file to true for a POST / inside the post method above.

This means we can re-use the same form whether doing a POST, PUT, or PATCH, even though PUT and PATCH don't allow changing the uploaded file.

Interestingly though, there's no mention of validation_groups anywhere in the form. Confusing. Well, validations live on the DTOs:

// src/AppBundle/DTO/FileDTO.php

class FileDTO implements FileInterface
{
    /**
     * @var string
     * @Assert\NotBlank(groups={"put","patch"})
     */
    private $name;

    /**
     * @var UploadedFile
     * @Assert\NotBlank(
     *     groups = { "post" },
     *     message = "A valid file is required"
     * )
     * @Assert\File(
     *     maxSize = "100M",
     *     groups = { "post" }
     * )
     */
    private $uploadedFile;

    // * snip *
}

Here you can see the validation groups in action.

If the validation_groups is set to post then we must include (NotBlank) a File that is no greater than 100m in size (maxSize).

We don't (actually, we can't!) include a file if the validation_groups is put or patch. However, if we are doing a put or a patch then a name property is required.

This ultimately leads to $fileDTO being properly populated (or throwing) inside the post method on our FileHandler.

Let's quickly recap the remaining steps:

// src/AppBundle/Handler/FileHandler.php
    public function post(array $parameters, array $options = [])
    {
        $fileDTO = /** snip */

        $file = $this->factory->createFromUploadedFile($fileDTO->getUploadedFile());

        $fileContents = $this->uploadFilesystem->getFileContentsFromPath($fileDTO->getUploadedFile()->getFilePath());
        $this->filesystem->put($file->getInternalFileName(), $fileContents);

        $account->addFile($file);

        $this->repository->save($file);

        return $file;
    }

A factory is used to create a File entity from the FileDTO.

Truthfully the factory is overkill here. I just like creating factories :)

// src/AppBundle/Factory/FileFactory.php
// comments and docblocks removed for brevity

class FileFactory implements FileFactoryInterface
{
    public function create($originalFileName, $internalFileName, $guessedExtension, $fileSize)
    {
        return new File($originalFileName, $internalFileName, $guessedExtension, $fileSize);
    }

    public function createFromUploadedFile(UploadedFileInterface $uploadedFile)
    {
        $internalFileName = sha1(uniqid(mt_rand(), true));

        return $this->create(
            $uploadedFile->getOriginalFileName(),
            $internalFileName,
            $uploadedFile->getFileExtension(),
            $uploadedFile->getFileSize()
        );
    }

The next line is peculiar:

$fileContents = $this->uploadFilesystem->getFileContentsFromPath($fileDTO->getUploadedFile()->getFilePath());

What is this uploadFilesystem and why can't we just do a file_get_contents or similar?

Well, PHPSpec absolutely hated that. So I ended up having to create my own wrapper around the way a file is retrieved. That way I could control the interface, even if the underlying operation was essentially just a file_get_contents:

// src/AppBundle/Util/UploadFilesystem.php

class UploadFilesystem implements FilesystemInterface
{
    public function getFileContentsFromPath($path)
    {
        return file_get_contents($path);
    }
}

Now we can finally hand over to the FlySystem to save the file to storage. Note - storage - not disk. It might be local storage, it might be Amazon S3, or DropBox, or any of the other filesystems that FlySystem easily allows us to use.

I am using local storage during testing and system build, but have switched out to S3 in prod. Here is the config though to use local storage in different locations, depending on your environment:

# app/config/config.yml

# Oneup Flysystem
oneup_flysystem:
    adapters:
        local_adapter:
            local:
                directory: %kernel.root_dir%/../uploads
                writeFlags: ~
                linkHandling: ~
    filesystems:
        local:
            adapter: local_adapter
            cache: ~
            alias: ~
            mount: ~

And nicely, we don't have to redeclare anything that stays the same, only the differences for any inheriting environments:

# app/config/config_acceptance.yml

# Oneup Flysystem
oneup_flysystem:
    adapters:
        local_adapter:
            local:
                directory: %kernel.root_dir%/../features/uploadTemp

The super nice thing about this is that we can reference the local_adapter in both prod and acceptance environments without changing any code, yet saving to different locations depending on which environment you use. Awesome.

FlySystem is a joy to work with. Check out the API - super easy, super useful. To get FlySystem into Symfony easily, I am using the awesome OneUpFlySystemBundle.

One last thing before we save off to the repository:

$account->addFile($file);

This step is pretty critical. If you don't do it, your file will be on disk but it won't know which Account it belongs too. Sad panda.

// src/AppBundle/Entity/Account.php

    /**
     * @param FileInterface $file
     * @return $this
     */
    public function addFile(FileInterface $file)
    {
        if ( ! $this->usesFile($file)) {
            $file->addAccount($this);
            $this->files->add($file);
        }

        return $this;
    }

This is perhaps one of the most mind bending parts of Symfony (well, Doctrine) so if you don't understand this, then watch this video series.

We've already covered off saving to the repository, and what happens after that, so I will leave this here. Be sure to watch the previous videos in this series - if you haven't already - to understand the parts not touched on in this write up.


Code For This Course

Get the code for this course.

Share This Episode

If you have found this video helpful, please consider sharing. I really appreciate it.


Episodes in this series

# Title Duration
1 Project Introduction 17:13
2 Setting Up Our Development Environment 05:08
3 Installing Symfony 3, Behat, and more 13:53
4 User Feature - Part 1 17:47
5 User Feature - Part 2 07:51
6 Talking English To Your Computer 11:05
7 Teaching Your Database To Forget 07:42
8 Creating User Data From Behat Background - Part 1 14:44
9 Creating User Data From Behat Background - Part 2 11:33
10 Creating A Custom RestApiContext 17:44
11 Our First Passing Behat User Scenario 12:01
12 Our Next Passing Step 13:10
13 Securing Our User Endpoint - Part 1 17:17
14 Securing Our User Endpoint - Part 2 24:27
15 Securing Our User Endpoint - Part 3 24:47
16 Log In To A Symfony API With JWTs (LexikJWTAuthenticationBundle) 11:02
17 Implementing PATCH for Users 18:17
18 Improving our API User Experience 13:59
19 GET a Collection of Accounts 12:15
20 POSTing in New Accounts 14:34
21 PUT and PATCH for Accounts 12:14
22 How To DELETE Existing Accounts 05:11
23 File Feature Overview 11:40
24 File - Using Existing Resources as Boilerplate 15:17
25 File POST 14:53
26 Fixing A Bug In POST Guided By Behat 12:50
27 Wrapping Up With File DELETE 07:47