If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeHow does a file upload work if you're building an API? Well, you have two options. First, you can make your API endpoint look exactly like what we already built in uploadArticleReference()
.
Let me show you what I mean. I'm going to use Postman to interact with our endpoint as if it were truly meant to be an API endpoint used by API clients. For the URL, copy the URL in the browser, paste, and change /edit
to /references
. Yep, that'll hit our controller. Make this a POST request.
What about the body of the request? What should that look like? Well, because we wrote our endpoint to basically handle a traditional form-submit, the format will be form-data
. For the key, remember that we're expecting the file data on a field called reference
. Change the field type to "file" and select earth.jpeg
.
That's it! Before trying this, our site is being served over https thanks to the Symfony local web server and some certificate magic it does behind the scenes. But Postman doesn't know to use that magic, so the certificate won't work. In the Postman preferences - I've already done it - turn SSL verification off. Or you can run the Symfony web server with the --allow-http
flag if you want to avoid this.
Ok, send the request! Oh... what's this? Check out the preview. The login page, of course! Uploading requires a valid user. Just to play around, let's remove the @IsGranted()
temporarily.
... lines 1 - 21 | |
class ArticleReferenceAdminController extends BaseController | |
{ | |
/** | |
* @Route("/admin/article/{id}/references", name="admin_article_add_reference", methods={"POST"}) | |
*/ | |
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager, ValidatorInterface $validator) | |
{ | |
... lines 29 - 75 | |
} | |
... lines 77 - 198 | |
} |
Try it again. Beautiful! It works!
So, the first way to build an upload endpoint for an API is... like this! An endpoint that requires the multipart form data format that we checked out at the beginning of this tutorial. Any API client will be able to work with this and a lot of API's are built this way.
But, there's another way. And if you're building an API, this might feel a little bit more natural. To see it, change the body to "raw", or actually, to JSON so we can set the request body manually, instead of Postman building it for us from the nice form-data
GUI.
When we change to use a JSON body, Postman helpfully auto-sets the Content-Type
header to application/json
, which depending on your API, you may or may not need. But it's always a good practice.
Ok, let's think about this from the perspective of a user of our API: if I want to send a file reference to a server, usually I'd expect the body to look something like this {"filename": "space.txt"}
with, maybe a bunch of other fields. Because... in an API, the request usually contains JSON! Not the weird form-data format.
Of course, space.txt
isn't the content of a file, but we would still probably want to be able to send the original filename. For the data, hmm, I'm just making this up, what if we create a data
key and put the binary data right here? That's great! Oh, except... you can't put binary data in JSON: it's just not supported.
API's work around this fact by expecting the client to base64 encode the data. Search for "base64 encode online" to find a site that can base64 encode some stuff for us really easily. Let's type in some text that we want to encode and... oops! We're on the decode side. Switch to encode and... there we go! We get this simple, encoded string. By the way, the main downside to this approach is that base64 encoded data is slightly bigger than the original data. On small or medium files, this makes very little difference. But if you're uploading huge files, using the base64 encoded data will slow things down, because more data needs to be transferred.
Anyways, paste that on the data
key. We know this won't work... because our controller is totally not set up to receive JSON, but pff. Let's try it anyways. Hit send and... validation error!
Please select a file to upload
Love it! Let's get to work. Back in our controller, to see what it looks like, let's make this endpoint capable of handling both ways of uploading files: form-data and JSON.
We can figure out which situation we're in by looking at the Content-Type
header. So, if $request->headers->get('Content-Type') === 'application/json'
, we'll do our new thing, else, run the normal code. And... this is pretty cool... the only part that'll really be different is the $uploadedFile
part. Move that into the else.
... lines 1 - 22 | |
class ArticleReferenceAdminController extends BaseController | |
{ | |
... lines 25 - 27 | |
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager, ValidatorInterface $validator, SerializerInterface $serializer) | |
{ | |
if ($request->headers->get('Content-Type') === 'application/json') { | |
... lines 31 - 42 | |
} else { | |
/** @var UploadedFile $uploadedFile */ | |
$uploadedFile = $request->files->get('reference'); | |
} | |
... lines 47 - 214 | |
} |
In the first part of the if, just like a normal API endpoint, we need to decode the JSON request content into something useful. To do that, let's use the serializer! Search for "deser", there it is. Earlier, we used deserialize()
to turn the JSON into an ArticleReference
object. That worked because the keys in that JSON matched the property names in that class.
But in this case, look at the fields: filename
and data
. We do have an originalFilename
field, and we could rename the filename
key to that... but we definitely do not have... and do not want a data
property on ArticleReference
that's equal to a base64 encoded version of our file. That makes no sense.
This is a classic case where the data of an endpoint doesn't match the structure of our entity. And that's cool! Instead of using the entity, we can create a new model class.
Inside src/
, let's create a new Api/
directory - just for organization - and inside, a new class: how about ArticleReferenceUploadApiModel
. The whole point of this class is to help us deal with the data for this endpoint. So, its properties should match the data. Add public $filename
and public $data
.
namespace App\Api; | |
... lines 4 - 6 | |
class ArticleReferenceUploadApiModel | |
{ | |
... lines 9 - 11 | |
public $filename; | |
... lines 13 - 16 | |
public $data; | |
} |
Yes! Gasp! They're public! Because this class will only be used for this one, narrow, purpose, it's ok to make life a bit easier with public properties. If this makes you want to scream and tackle me, I get it! Just make them private and add the getter & setter methods. That will work perfectly.
While we're here, don't forget about validation: add @Assert\NotBlank
above both of these.
namespace App\Api; | |
use Symfony\Component\Validator\Constraints as Assert; | |
class ArticleReferenceUploadApiModel | |
{ | |
/** | |
* @Assert\NotBlank() | |
*/ | |
public $filename; | |
/** | |
* @Assert\NotBlank() | |
*/ | |
public $data; | |
} |
We're ready! Back in the controller add a new argument at the end: SerializerInterface $serializer
. Then, it's beautiful, really $uploadApiModel = $serializer->deserialize()
. This takes three arguments: the raw JSON - $request->getContent()
- the type of object it should be turned into - ArticleReferenceUploadApiModel::class
- and the input format, json
.
... lines 1 - 4 | |
use App\Api\ArticleReferenceUploadApiModel; | |
... lines 6 - 22 | |
class ArticleReferenceAdminController extends BaseController | |
{ | |
... lines 25 - 27 | |
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager, ValidatorInterface $validator, SerializerInterface $serializer) | |
{ | |
if ($request->headers->get('Content-Type') === 'application/json') { | |
$uploadApiModel = $serializer->deserialize( | |
$request->getContent(), | |
ArticleReferenceUploadApiModel::class, | |
'json' | |
); | |
... lines 36 - 42 | |
} else { | |
... lines 44 - 45 | |
} | |
... lines 47 - 214 | |
} |
We don't need a context this time, because we're not deserializing into an existing object and we don't need to use groups.
And because this object has some constraints, we'll need to check validation up here: $violations = $validator->validate($uploadApiModel)
. And if $violations->count() > 0
, return the normal, $this->json($violations, 400)
.
... lines 1 - 4 | |
use App\Api\ArticleReferenceUploadApiModel; | |
... lines 6 - 22 | |
class ArticleReferenceAdminController extends BaseController | |
{ | |
... lines 25 - 27 | |
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager, ValidatorInterface $validator, SerializerInterface $serializer) | |
{ | |
if ($request->headers->get('Content-Type') === 'application/json') { | |
... lines 31 - 36 | |
$violations = $validator->validate($uploadApiModel); | |
if ($violations->count() > 0) { | |
return $this->json($violations, 400); | |
} | |
... lines 41 - 42 | |
} else { | |
... lines 44 - 45 | |
} | |
... lines 47 - 214 | |
} |
At the bottom, let's dd($uploadApiModel)
so we can see if this crazy idea is working.
... lines 1 - 4 | |
use App\Api\ArticleReferenceUploadApiModel; | |
... lines 6 - 22 | |
class ArticleReferenceAdminController extends BaseController | |
{ | |
... lines 25 - 27 | |
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager, ValidatorInterface $validator, SerializerInterface $serializer) | |
{ | |
if ($request->headers->get('Content-Type') === 'application/json') { | |
... lines 31 - 36 | |
$violations = $validator->validate($uploadApiModel); | |
if ($violations->count() > 0) { | |
return $this->json($violations, 400); | |
} | |
dd($uploadApiModel); | |
} else { | |
... lines 44 - 45 | |
} | |
... lines 47 - 214 | |
} |
You ready to try this? Spin back over to Postman, high-five someone near you and... send! Hey! Check out that beautiful dump! The text is still encoded, but that's a killer first step. Leave the filename
blank to check validation. Looks great.
Let's finish this next: we still need to base64 decode that data and push it into our normal file upload system. Let's do that in a clean way that we can love.
Hey Stileex,
Hm, it's mostly a matter of taste, though here are some tips: First of all, if you're looking for a ready-to-use solution - you can leverage the VichUploaderBundle I think. It already has some low-level code written for you, as any other bundle, so it gives you some tools. And so, this sounds like more simple and faster solution.
Or you can go with this course and do all the uploads yourself, but it would mean that you will probably need to write more code as you will have to write that low-level code yourself. However, from the other side, as a benefit, this solution might be also more flexible, and sometimes is better if you need to do some really custom things for your project. And as another benefit, you will know how file uploads works in Symfony behind the scene :)
So, it's up to you! Choose whatever path you like more.
I hope this helps you to make a good choice.
Cheers!
When I run the POST to add a reference, I get the error "Invalid API Token" even though I removed the IsGranted from the annotations. What did I miss?
Hey Skylar
Are you sure you removed all the security? Maybe you are using an Access control list? Check you security.yaml
file
Cheers!
I am using the files in the downloaded code Zip file. I have not changed security.yaml. I just followed along with the tutorial.
"Are you sure you removed all the security?" -- what do you mean? I removed the @IsGranted annotation. Is there something additional I need to do? what is triggering the ApiAuthentication?
Hey Skylar!
Hmm. Yea, you're totally right - it's definitely coming from the ApiTokenAuthenticator
inside src/Security
. Double-check that you don't have any "Authentication" headers set inside Postman - you might still have something set from some earlier work you were doing. The ApiTokenAuthenticator should only do its work and cause that error when You are sending an Authorization: Bearer XXXXXX
header on the request.
Let us know what you find!
Cheers!
That was it!! I changed auth to "No Auth" and I was able to continue to follow along. Thank you so much for this Awesome tutorial. It's more than just code...it's how to enjoy the process!!
Hey Mouad E.
At the moment we are releasing the "Symfony uploads" tutorial, it's pretty cool, you may enjoy watching it. After that, we are going to release a course about Symfony + API Platform. React Redux is on our scope but just not yet.
Cheers!
Or maybe Symfony translations before API Platform - not entirely sure - a few are being planned currently!
That's coming soon! I need to fill in after this tutorial with a quick tutorial about Webpack Encore to get it updated, but then I'm right back to making sure we get that API Platform tutorial out as quickly as possible.
// composer.json
{
"require": {
"php": "^7.1.3",
"ext-iconv": "*",
"aws/aws-sdk-php": "^3.87", // 3.87.10
"composer/package-versions-deprecated": "^1.11", // 1.11.99
"knplabs/knp-markdown-bundle": "^1.7", // 1.7.1
"knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
"knplabs/knp-time-bundle": "^1.8", // 1.9.0
"league/flysystem-aws-s3-v3": "^1.0", // 1.0.22
"league/flysystem-cached-adapter": "^1.0", // 1.0.9
"liip/imagine-bundle": "^2.1", // 2.1.0
"nexylan/slack-bundle": "^2.0,<2.2.0", // v2.1.0
"oneup/flysystem-bundle": "^3.0", // 3.0.3
"php-http/guzzle6-adapter": "^1.1", // v1.1.1
"sensio/framework-extra-bundle": "^5.1", // v5.2.4
"stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
"symfony/asset": "^4.0", // v4.2.3
"symfony/console": "^4.0", // v4.2.3
"symfony/flex": "^1.9", // v1.17.6
"symfony/form": "^4.0", // v4.2.3
"symfony/framework-bundle": "^4.0", // v4.2.3
"symfony/orm-pack": "^1.0", // v1.0.6
"symfony/security-bundle": "^4.0", // v4.2.3
"symfony/serializer-pack": "^1.0", // v1.0.2
"symfony/twig-bundle": "^4.0", // v4.2.3
"symfony/validator": "^4.0", // v4.2.3
"symfony/web-server-bundle": "^4.0", // v4.2.3
"symfony/yaml": "^4.0", // v4.2.3
"twig/extensions": "^1.5" // v1.5.4
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.0", // 3.1.0
"easycorp/easy-log-handler": "^1.0.2", // v1.0.7
"fzaninotto/faker": "^1.7", // v1.8.0
"symfony/debug-bundle": "^3.3|^4.0", // v4.2.3
"symfony/dotenv": "^4.0", // v4.2.3
"symfony/maker-bundle": "^1.0", // v1.11.3
"symfony/monolog-bundle": "^3.0", // v3.3.1
"symfony/phpunit-bridge": "^3.3|^4.0", // v4.2.3
"symfony/profiler-pack": "^1.0", // v1.0.4
"symfony/var-dumper": "^3.3|^4.0" // v4.2.3
}
}
Hi there !
I am about to implement file upload in my API Platform software. What do you advise me :
1. Implementing the file upload as described in this course
2. Or going with VichUploaderBundle as mentioned in Api Platform documentation ?
Thanks !