File Feature Overview


In this video we are going to cover the Behat feature for the File resource. Much like in the previous two resources (Account, and User), we need to tell Behat how to convert between the English language of our background and feature step definitions, to a format that PHP can understand.

There's only a few extra pieces needed to set up and test File resources, so let's start with the Behat background step for File:

# src/AppBundle/Features/file.feature

  Background:
    Given there are users with the following details:
      * snip *
     And there are files with the following details:
      | uid  | originalFileName         | internalFileName | guessedExtension | size | dummyFile             |
      | f1   | some long file name.jpg  | intfile1         | jpg              | 100  | Image/pk140.jpg       |
      | f2   | not_terrible.unk         | intfile2         | bin              | 20   | Image/phit200x100.png |
      | f3   | ok.png                   | intfile3         | png              | 666  |                       |

If you are in any way familiar with Symfony's UploadedFile, you will likely recognised these field headers - except the dummyFile one which is how we can make sure a file exists on disk for test purposes.

And of course, as this is a Behat background step definition, this will re-run per scenario in our test suite. This way, we can ensure certain files and entities exist on disk, and in the database, so we can test deleting records removes the associated file, etc.

We'll cover this in more detail, but the extra files needed to make this work are as follows:

<?php

// src/AppBundle/Features/Context/FileSetupContext.php

namespace AppBundle\Features\Context;

use AppBundle\Factory\FileFactoryInterface;
use Behat\Behat\Context\Context;
use Behat\Behat\Context\SnippetAcceptingContext;
use Behat\Gherkin\Node\TableNode;
use Doctrine\ORM\EntityManagerInterface;
use FOS\UserBundle\Model\UserManagerInterface;
use League\Flysystem\Filesystem;

class FileSetupContext implements Context, SnippetAcceptingContext
{
    use \Behat\Symfony2Extension\Context\KernelDictionary;

    /**
     * @var EntityManagerInterface
     */
    protected $em;
    /**
     * @var UserManagerInterface
     */
    protected $userManager;
    /**
     * @var FileFactoryInterface
     */
    private $fileFactory;
    /**
     * @var Filesystem
     */
    private $filesystem;
    /**
     * @var string
     */
    private $dummyDataPath;

    /**
     * FileSetupContext constructor.
     * @param UserManagerInterface      $userManager
     * @param FileFactoryInterface      $fileFactory
     * @param EntityManagerInterface    $em
     * @param Filesystem                $filesystem
     * @param string                    $dummyDataPath
     */
    public function __construct(
        UserManagerInterface $userManager,
        FileFactoryInterface $fileFactory,
        EntityManagerInterface $em,
        Filesystem $filesystem,
        $dummyDataPath
    )
    {
        $this->userManager = $userManager;
        $this->fileFactory = $fileFactory;
        $this->em = $em;
        $this->filesystem = $filesystem;
        $this->dummyDataPath = $dummyDataPath;
    }

    /**
     * @AfterScenario
     */
    public static function removeUploadedFiles()
    {
        foreach (glob(__DIR__ . '/../../../../uploads/*') as $file) {
            echo "Removing uploaded file: \n";
            echo basename($file) . "\n";
            unlink($file);
        }
    }

    /**
     * @Given there are files with the following details:
     */
    public function thereAreFilesWithTheFollowingDetails(TableNode $files)
    {
        foreach ($files->getColumnsHash() as $key => $val) {

            // we will cover the File Factory in a future video - truthfully you could get away with
            // just new File() here
            $file = $this->fileFactory->create(
                $val['originalFileName'],
                $val['internalFileName'],
                $val['guessedExtension'],
                $val['size']
            );

            $this->em->persist($file);
            $this->em->flush();

            $qb = $this->em->createQueryBuilder();

            // this has been covered in the previous Behat setup videos
            // simply - we are replacing the generated GUID with the fake ID we can test against 
            $query = $qb->update('AppBundle:File', 'f')
                ->set('f.id', $qb->expr()->literal($val['uid']))
                ->where('f.internalFileName = :internalFileName')
                ->setParameters([
                    'internalFileName' => $val['internalFileName'],
                ])
                ->getQuery()
            ;

            $query->execute();

            // ensure we handle any situation where the dummyFile entry is left blank
            if ( ! empty($val['dummyFile'])) {
                $this->filesystem->put(
                    $val['internalFileName'],
                    file_get_contents($this->dummyDataPath . $val['dummyFile'])
                );
            }
        }

        $this->em->flush();
    }
}

and:

<?php

// src/AppBundle/Features/Context/FileStorageContext.php

namespace AppBundle\Features\Context;

use Behat\Behat\Context\Context;
use Behat\Behat\Context\SnippetAcceptingContext;
use League\Flysystem\Filesystem;

class FileStorageContext implements Context, SnippetAcceptingContext
{
    use \Behat\Symfony2Extension\Context\KernelDictionary;

    /**
     * @var Filesystem
     */
    private $filesystem;

    /**
     * FileStorageContext constructor.
     * @param Filesystem $filesystem
     */
    public function __construct(Filesystem $filesystem)
    {
        $this->filesystem = $filesystem;
    }

    /**
     * @Then the file with internal name: :internalName should have been deleted
     */
    public function theFileWithInternalNameShouldHaveBeenDeleted($internalName)
    {
        // this method comes from with FlySystem... thank you! 
        $this->filesystem->assertAbsent($internalName);
    }

    /**
     * @Then the file with internal name: :internalName should not have been deleted
     */
    public function theFileWithInternalNameShouldNotHaveBeenDeleted($internalName)
    {
        // and so does this
        $this->filesystem->assertPresent($internalName);
    }
}

And the behat.yml entries:

default:
  suites:
    default:
      type: symfony_bundle
      bundle: AppBundle
      contexts:
        - AppBundle\Features\Context\FileSetupContext:
            userManager: "@fos_user.user_manager"
            fileFactory: "@a6.factory.file_factory"
            em: "@doctrine.orm.entity_manager"
            filesystem: "@oneup_flysystem.local_filesystem"
            dummyDataPath: "%paths.base%/features/Dummy/"
        - AppBundle\Features\Context\FileStorageContext:
            filesystem: "@oneup_flysystem.local_filesystem"

Covering the Differences

Now, as we have already covered each of the REST verbs in previous videos, for the File resource we are only going to cover the methods with differences. These will be POST and DELETE primarily.

POST is different because we will be using a Content-Type of multipart/form-data - just like you would with a regular old HTML file upload form field. You may not wish to do this, and you are free to use base64 strings or any other method you may know of to get file data into your system.

DELETE is similar in so much as we will still delete the entity / record from our database, but we also need to handle the removal of the file from storage as well.

Notice here that I talk about removing a file from storage, and not necessarily from disk. That is to say, the file will be on disk somewhere - but that need not be your local server hard disk. We will be using FlySystem for our file system abstraction.

FlySystem is an alternative to Guafrette, both designed for the same purpose. Guafrette seems to have slightly better adoption in the Symfony community.

However, I chose FlySystem as it rocks. In my opinion, it is easier to configure than Guafrette and just as powerful. Essentially this will allow us to save our files off to local disk, to Amazon S3, or DropBox, or RackSpace, or one of the many other supported platforms.

Also, the documentation is a joyous thing, and super easy to understand. We will integrate this into our Symfony API with the Oneup / FlySystem Bundle. Don't worry too much about this for now, we will cover this in more detail shortly.

PATCH and PUT will both allow changing / updating the File 'metadata' - displayed file name being the only implemented field here. I made the decision that if a User uploads the wrong file, it is easier to force them to delete that file and re-upload, rather than implementing a facility to replace a file.

I know, I know, this is a bit of a cop out. My reasoning for this is that I needed to get this system to market first and start validating the underlying business model, rather than having the ultimate, perfect API from day one.

Why Nested Resources?

Whilst Account and User are both top level resources, File is nested under Account.

Visually this looks like:

/accounts/{accountId}/files/{fileId}.

This is a design decision on my part for the following purpose.

The system I had in mind would cater for multiple Users logging in concurrently, and managing a subset of all Accounts.

This may be that the system has 10 Accounts.

User A has access to Account 1, 2, and 3.

User B has access to Account 1, 6, and 10.

Because each Account is isolated, I did not want Files to 'leak' between Accounts. Which is to say that a User shouldn't see all uploaded Files when inside an Account. Instead, they should see only the Files related to the current Account.

Nesting resources is acceptable, but try not to nest deeper than one level. Beyond that I have found not only is it difficult to mentally comprehend, but that likely the system could have been architected differently to remove many of the nested layers in the first place.

Code For This Course

Get the code for this course.

Episodes