Buy Access to Course
40.

Coding the API Upload Endpoint

Share this awesome video!

|

Keep on Learning!

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Our controller is reading this JSON and decoding it into a nice ArticleReferenceUploadApiModel object. But the data property on that is still base64 encoded.

base64_decode from the Model Class

Decoding is easy enough. But let's make our new model class a bit smarter to help with this. First, change the data property to be private. If we only did this, the serializer would no longer be able to set that onto our object.

27 lines | src/Api/ArticleReferenceUploadApiModel.php
// ... lines 1 - 6
class ArticleReferenceUploadApiModel
{
// ... lines 9 - 16
private $data;
// ... lines 18 - 25
}

Hit "Send" to see this. Yep! the data key is ignored: it's not a field the client can send, because there's no setter for it and it's not public. Then, validation fails because that field is still empty.

So, because I've mysteriously said that we should set the property to private, add a public function setData() with a nullable string argument... because the user could forget to send that field. Inside, $this->data = $data.

27 lines | src/Api/ArticleReferenceUploadApiModel.php
// ... lines 1 - 6
class ArticleReferenceUploadApiModel
{
// ... lines 9 - 16
private $data;
// ... lines 18 - 20
public function setData(?string $data)
{
$this->data = $data;
// ... line 24
}
}

Now, create another property: private $decodedData. And inside the setter, $this->decodedData = base64_decode($data). And because this is private and does not have a setter method, if a smart user tried to send a decodedData key on the JSON, it would be ignored. The only valid fields are filename - because it's public - and data - because it has a setter.

27 lines | src/Api/ArticleReferenceUploadApiModel.php
// ... lines 1 - 6
class ArticleReferenceUploadApiModel
{
// ... lines 9 - 16
private $data;
private $decodedData;
public function setData(?string $data)
{
$this->data = $data;
$this->decodedData = base64_decode($data);
}
}

Try it again. It's working and the decoded data is ready! It's a simple string in our case, but this would work equally well if you base64 encoded a PDF, for example.

Saving a Temporary File

Let's look at the controller. We know the "else" part, that's the "traditional" upload part, is working by simply setting an $uploadedFile object and letting the rest of the controller do its magic. So, if we can create an UploadedFile object up here, we're in business! It should go through validation... and process.

If you remember from our fixtures, we can't actually create UploadedFile objects - it's tied to the PHP upload process. But we can create File objects. Open up ArticleFixtures. At the bottom, yep! We create a new File() - that's the parent class of UploadedFile and pass it $targetPath, which is the path to a file on the filesystem. UploaderHelper can already handle this.

In the controller, we can do the same thing. Start by setting $tmpPath to sys_get_temp_dir() plus '/sf_upload'.uniqueid() to guarantee a unique, temporary file path. Yep, we're literally going to save the file to disk so our upload system can process it. We could also enhance UploaderHelper to be able to handle the content as a string, but this way will re-use more logic.

// ... lines 1 - 23
class ArticleReferenceAdminController extends BaseController
{
// ... lines 26 - 28
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager, ValidatorInterface $validator, SerializerInterface $serializer)
{
if ($request->headers->get('Content-Type') === 'application/json') {
// ... lines 32 - 43
$tmpPath = sys_get_temp_dir().'/sf_upload'.uniqid();
// ... lines 45 - 47
} else {
// ... lines 49 - 50
}
// ... lines 52 - 96
}
// ... lines 98 - 219
}

To get the raw content, go back to the model class. We need a getter. Add public function getDecodedData() with a nullable string return type. Then, return $this->decodedData.

32 lines | src/Api/ArticleReferenceUploadApiModel.php
// ... lines 1 - 6
class ArticleReferenceUploadApiModel
{
// ... lines 9 - 26
public function getDecodedData(): ?string
{
return $this->decodedData;
}
}

Now we can say: file_put_contents($tmpPath, $uploadedApiModel->getDecodedData()). Oh, I'm not getting any auto-completion on that because PhpStorm doesn't know what the $uploadedApiModel object is. Add some inline doc to help it. Now, $this->, got it - getDecodedData().

// ... lines 1 - 23
class ArticleReferenceAdminController extends BaseController
{
// ... lines 26 - 28
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager, ValidatorInterface $validator, SerializerInterface $serializer)
{
if ($request->headers->get('Content-Type') === 'application/json') {
/** @var ArticleReferenceUploadApiModel $uploadApiModel */
$uploadApiModel = $serializer->deserialize(
// ... lines 34 - 36
);
// ... lines 38 - 43
$tmpPath = sys_get_temp_dir().'/sf_upload'.uniqid();
file_put_contents($tmpPath, $uploadApiModel->getDecodedData());
// ... lines 46 - 47
} else {
// ... lines 49 - 50
}
// ... lines 52 - 96
}
// ... lines 98 - 219
}

Finally, set $uploadedFile to a new File() - the one from HttpFoundation. Woh! That was weird - it put the full, long class name here. Technically, that's fine... but why? Undo that, then go check out the use statements. Ah: this is one of those rare cases where we already have another class imported with the same name: File. Let's add our use statement manually, then alias is to, how about, FileObject. I know, a bit ugly, but necessary.

Below, new FileObject() and pass it the temporary path. Let's dd() that.

// ... lines 1 - 11
use Symfony\Component\HttpFoundation\File\File as FileObject;
// ... lines 13 - 23
class ArticleReferenceAdminController extends BaseController
{
// ... lines 26 - 28
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager, ValidatorInterface $validator, SerializerInterface $serializer)
{
if ($request->headers->get('Content-Type') === 'application/json') {
// ... lines 32 - 43
$tmpPath = sys_get_temp_dir().'/sf_upload'.uniqid();
file_put_contents($tmpPath, $uploadApiModel->getDecodedData());
$uploadedFile = new FileObject($tmpPath);
dd($uploadedFile);
} else {
// ... lines 49 - 50
}
// ... lines 52 - 96
}
// ... lines 98 - 219
}

Phew! Back on Postman, hit send. Hey! That looks great! Copy that filename, then, wait! That was just the directory - copy the actual filename - called pathname, find your terminal and I'll open that in vim.

Getting the "Client Original Name"

Yes! The contents are perfect! So... are we done? Let's find out! Take off the dd(), move over and... this is our moment of glory... send! Oh, boo! No glory, just errors. Life of a programmer.

Undefined method getClientOriginalName() on File.

This comes from down here on line 84. Ah yes, the UploadedFile object has a few methods that its parent File does not. Notably getClientOriginalName().

No problem, back up, create an $originalName variable on both sides of the if. For the API style, set it to $uploadApiModel->filename: the API client will send this manually. For the else, set $originalName to $uploadedFile->getClientOriginalName(). Now, copy $originalName, head back down to setOriginalFilename() and paste! And if for some reason it's not set, we can still use $filename as a backup. But that's definitely impossible for our API-style thanks to the validation rules.

// ... lines 1 - 23
class ArticleReferenceAdminController extends BaseController
{
// ... lines 26 - 28
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager, ValidatorInterface $validator, SerializerInterface $serializer)
{
if ($request->headers->get('Content-Type') === 'application/json') {
// ... lines 32 - 46
$originalFilename = $uploadApiModel->filename;
} else {
// ... lines 49 - 50
$originalFilename = $uploadedFile->getClientOriginalName();
}
// ... lines 53 - 83
$articleReference->setOriginalFilename($originalFilename ?? $filename);
// ... lines 85 - 97
}
// ... lines 99 - 220
}

Deep breath. Let's try it again. Woh! Did that just work? It looks right. Go refresh the browser. Ha! We have a space.txt file! And we can even download it! Go check out S3 - the article_reference directory.

Oh, interesting! The files are prefixed with sf-uploads - that's the temporary filename we created on the server. That's because UploaderHelper uses that to create the unique filename. And really, that's fine! These filenames are 100% internal. But if it bothers you, you could use the original filename to help make the temporary file.

Anyways... we did it! A fully JSON-driven API upload endpoint. Fun, right?

Removing the Temporary File

Before we finish... and ride off into the sunset, as champions of uploading in Symfony, let's make sure we delete that temporary file after we finish.

All the way down here, before persist, but after we've tried to read the mime type from the file, add, if is_file($uploadedFile->getPathname()), then delete it: unlink($uploadedFile->getPathname()).

// ... lines 1 - 23
class ArticleReferenceAdminController extends BaseController
{
// ... lines 26 - 28
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager, ValidatorInterface $validator, SerializerInterface $serializer)
{
// ... lines 31 - 84
$articleReference->setMimeType($uploadedFile->getMimeType() ?? 'application/octet-stream');
if (is_file($uploadedFile->getPathname())) {
unlink($uploadedFile->getPathname());
// ... line 89
}
$entityManager->persist($articleReference);
// ... lines 93 - 102
}
// ... lines 104 - 225
}

The if is sorta unnecessary, but I like it. To double-check that this works, let's dd($uploadedFile->getPathname()), go find Postman and send. Copy the path, find your terminal, and try to open that file. It's gone!

// ... lines 1 - 23
class ArticleReferenceAdminController extends BaseController
{
// ... lines 26 - 28
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager, ValidatorInterface $validator, SerializerInterface $serializer)
{
// ... lines 31 - 84
$articleReference->setMimeType($uploadedFile->getMimeType() ?? 'application/octet-stream');
if (is_file($uploadedFile->getPathname())) {
unlink($uploadedFile->getPathname());
dd($uploadedFile->getPathname());
}
$entityManager->persist($articleReference);
// ... lines 93 - 102
}
// ... lines 104 - 225
}

Celebrate by removing that dd() and sending one last time. I'm so happy.

// ... lines 1 - 23
class ArticleReferenceAdminController extends BaseController
{
// ... lines 26 - 28
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager, ValidatorInterface $validator, SerializerInterface $serializer)
{
// ... lines 31 - 86
if (is_file($uploadedFile->getPathname())) {
unlink($uploadedFile->getPathname());
}
// ... lines 90 - 101
}
// ... lines 103 - 224
}

Oh, and don't forget to put security back: @IsGranted("MANAGE", subject="article"). In a real project, wherever I test my API endpoints - like Postman or via functional tests, I would actually authenticate myself properly so they worked, instead of temporarily hacking out security. Generally speaking, removing security is, uh, not a great idea.

// ... lines 1 - 23
class ArticleReferenceAdminController extends BaseController
{
/**
* @Route("/admin/article/{id}/references", name="admin_article_add_reference", methods={"POST"})
* @IsGranted("MANAGE", subject="article")
*/
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager, ValidatorInterface $validator, SerializerInterface $serializer)
{
// ... lines 32 - 102
}
// ... lines 104 - 225
}

Hey! That's it! We did it! Woh! I had a ton of a fun making this tutorial - we got to play with uploads, a bunch of cool libraries and... the cloud. Uploading is fairly simple, but there can be a lot of layers to keep track of, like Flysystem and LiipImagineBundle.

As always, let us know what you're building and if you have questions, ask them in the comments. Alright friends, seeya next time!