Coding the API Upload Endpoint

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $10.00

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.

... 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.

... 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.

... 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.

... 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!

Leave a comment!

  • 2020-05-02 GrandOurs

    Awesome tutorial, thank you so much! I learned so much :)

  • 2020-04-09 Victor Bocharsky

    Hey Michael,

    Yes, it should work I think. Just look into docs, for example: https://github.com/liip/Lii... - on how to resolve link in PHP. And it should be no matter if you use local storage or AWS S3.

    I hope this helps!

    Cheers!

  • 2020-04-07 Michael Brauner

    How would that work, when I use API-Platform. Is there an easy way to use LiipImagineBundle and AWS S3?

  • 2019-09-11 sheepwall

    Thanks a lot for this tutorial series! Most of this would have been a trip through the documentation jungle to figure out on my own!

  • 2019-06-13 xdrew

    Thank you, Ryan!
    I'll give it a try

  • 2019-06-11 weaverryan

    Hey xdrew!

    Sorry again for my slow reply! I think you're reading too much into the recommendations from API Platform. Specifically, the MediaObject idea is simply *one* pattern for handling file uploads that (I think) they choose as an example for talking about uploads in their docs. Well, that's probably not *entirely* true. The idea of a MediaObject makes it possible to have nice RESTful endpoints, because you can, for example, POST to /medias to create a "Media" resource, then, for example, make a PUT request to /products to attach that Media resource to some Product resource. However, as good as it is to try to think in RESTful ways, sometimes breaking the rules is more pragmatic, and should allow you to use your current way of doing things without too much trouble. Here's a high-level description of how it would look - using some "Product" entity as an example.

    1) You already have a Product entity and it's an ApiResource. Let's also assume you've got your standard picture and pictureFile properties on it like you wrote above.

    2) Create a custom operation for your Product entity e.g. POST to /products/{id}/image (or something like that) - this custom operation will follow very closely what you see in their docs (it'll be multipart-form-data for example).

    3) Create the custom controller for the operation and, more or less, handle the file upload like normal. In this endpoint, you would set the "picture" property on the Product entity after processing the upload.

    This should give you what you want with the setup you already have - but let me know if you hit issues or have questions. The non-RESTful part of this is the /products/{id}/image endpoint ... as what you're *really* doing by uploading is editing the Product resource, so you're not supposed to create a new URL for this. As long as you're aware when you're bending the rules and do it responsibly, I think you're in good shape.

    Cheers!

  • 2019-06-07 xdrew

    Thanks for the reply!
    I'll try to explain my "entity specific validations" phrase:

    I have a standard Symfony app which handles uploads as that:

    /**
    * @var string
    *
    * @ORM\Column(name="picture", type="string", nullable=true)
    */
    private $picture;

    /**
    * @var File
    *
    * @Vich\UploadableField(mapping="good_picture", fileNameProperty="picture")
    *
    * @Assert\Image(
    * maxWidth=247,
    * maxHeight=170,
    * maxSize="500k",
    * )
    */
    private $pictureFile;

    So as you may guess from this code I just use a regular form with an unmapped property and after validation and successful upload I store the filepath into a mapped one. I have a bunch of such entities. Some of them have multiple files with different validations.

    Now I move my application to api platform and if I understood their approach correctly it boils down to:
    1. Have separate entity for all files (MediaObject)
    2. Create custom controller, creating such files via submitting multipart requests and a listener, handling file paths
    3. On the client side first upload a file, and then with another normal api platform request we attach this file to our entity.

    The issue I see here is that the normal file validation annotation is applicable only to MediaObject entity. And if one of my files has maxSize of 500kb and the other one 50mb — I have no control over it at the "upload" stage.
    I could create some custom validation on ManyToOne side inside my entity (ie. when we attach already uploaded file), but it then makes me implement logic for removing invalid files.

    Duplicating the whole MediaObject for each file property is a bit redundant)

    So for now I ended up with the following solution:

    final class CreateMediaObjectAction {
    ...
    public function __invoke(Request $request): MediaObject
    {
    $uploadedFile = $request->files->get('file');
    if (!$uploadedFile) {
    throw new BadRequestHttpException('"file" is required');
    }
    $type = $request->get('type');

    $mediaObject = new MediaObject();
    $mediaObject->setFile($uploadedFile);
    $mediaObject->setType($type);

    $validator = $this->getValidator($type);
    $violationList = $validator->validate($mediaObject);

    if ($violationList->count()) {
    throw new ValidationException($violationList);
    }

    return $mediaObject;
    }

    protected function getValidator($type)
    {
    $baseDir = $this->kernel->getProjectDir() . '/config/validation/media';
    $filePath = $baseDir.'/'.$type.'.yml';
    if (!$this->filesystem->exists($filePath)) {
    $filePath = $baseDir.'/default.yml';
    }
    return Validation::createValidatorBuilder()
    ->addYamlMapping($filePath)
    ->addMethodMapping('loadValidatorMetadata')
    ->getValidator();
    }
    ...
    }

    So the Controller still handles all files, but it also accepts "type" field that is used for searching for a specific validation rule. It looks like this:
    App\Entity\MediaObject:
    properties:
    file:
    - Image:
    maxWidth: 500

    And the entity validation just checks whether the submitted file has the right type:

    /**
    * @ORM\ManyToOne(targetEntity="App\Entity\MediaObject", cascade={"persist"})
    * @Assert\Expression("!this.getPicture() or this.getPicture().getType() === 'food_picture'")
    */
    private $picture;

    It works, but I was trying to find more elegant solution for such a trivial task)

  • 2019-06-07 weaverryan

    Hey xdrew!

    That's an excellent question :). We talk about uploads in general in our uploads tutorial (https://symfonycasts.com/sc... and include a section at the end about how uploads *sometimes* look in an API (but not in API Platform). The docs around uploads in API Platform basically come down to the fact that uploads are a bit custom and, at least according to the docs right now, are best done with a custom controller. Basically, I would use the same approach as shown in the docs, except I probably wouldn't use VichUploaderBundle - I just personally prefer processing the upload stuff myself. The key thing is that, unless you follow how we do uploads in the last 2 chapters of our upload tutorial (which is not a better approach, just an alternate approach), upload endpoints are weird: you're not sending data as JSON, you're sending it as multipart form data.

    I'm not sure I've given you a great answer :). You mentioned "doesn't seem to allow entity specific validations" - what do you mean by that? Do you want an endpoint where your API client sends a file upload and other fields, and you want to be able to validate those fields (via the annotations on your entity)?

    Cheers!

  • 2019-06-06 Vladimir Sadicov

    Thanks Edin . GL, HF! And stay tuned we have a lot of cool stuff!

  • 2019-06-06 Edin

    GG

  • 2019-06-01 xdrew

    What approach would you recommend for uploading in an api platfrom based app? The official way ( https://api-platform.com/do... ) doesn't seem to allow entity specific validations.

  • 2019-05-23 weaverryan

    LOL - thanks Edison Valdez :D

  • 2019-05-23 Edison Valdez

    What such a great tutorial! You Guys did it again, making things look easier (and funnier) than they really are.

  • 2019-05-16 Rafail

    It was awesome! Thanks a lot!