File POST
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 throw
ing) 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.