Django REST Framework (DRF) is a popular and powerful framework for building APIs in Django. Although it advocates and supports building RESTful APIs out of the box, it's flexible enough to let the developer do whatever it wants.
One of the things many developers do with DRF is handle file uploads the same way they handled file uploads in the old days: via as multi-part form data. In fact, when you search for django rest framework how to upload file, more than half of the results use this approach.
Although this approach works, I would argue that sending such a request with encoded file in the body is not very RESTful. Instead, in this case I prefer my API to accept a POST or PUT request with a file being directly uploaded, with the request body being thefile content.
Binary file upload
DRF makes this approach easy with FileUploadParser.
A parser in DRF is a component that takes a raw request from the client and parses it into parts. Examples of
parsers include JSON parser (for JSON requests) and form parser (for HTML forms). FileUploadParser
assumes the
entire request body is a single file, which is exactly what we want.
Here's how we can use this approach and save content in a FileField in a Django model:
from rest_framework.exceptions import ParseError
from rest_framework.parsers import FileUploadParser
from rest_framework.response import Response
from rest_framework.views import APIView
class MyUploadView(APIView):
parser_class = (FileUploadParser,)
def put(self, request, format=None):
if 'file' not in request.data:
raise ParseError("Empty content")
f = request.data['file']
mymodel.my_file_field.save(f.name, f, save=True)
return Response(status=status.HTTP_201_CREATED)
We override parser classes for the view, instructing DRF to only use FileUploadParser
. If successful, it
will store the uploaded content in the file
item in the request data. Small content will be kept in memory,
while larger uploads will automatically use temporary files on disk, as usual with Django and DRF, but this
implementation detail is hidden so you don't have to worry about it.
In our view, we check that there is some content, and if not, raise a ParseError
(which will result in HTTP 400
Bad Request response to the client). If everything is fine, we save the content to a FileField in our model.
In this example we used a PUT method, but we could have used POST just as well. Just note than when doing uplodas
this way, the client needs to signal the content filename to the service. It can either do so by specifying a
file name in the path (URI) itself, or add a Content-Disposition
header to the request. The
FileUploadParser documentation has a
detailed description of both methods.
Deleting uploaded content
All this works great if we want to upload a file, but it'd also be nice if we could delete it using the DELETE
HTTP method on the same endpoint. To do that, let's add a delete
method to our view:
class MyUploadView(APIView):
...
def delete(self, request, format=None):
mymodel.my_file_field.delete(save=True)
return Response(status=status.HTTP_204_NO_CONTENT)
Content validation
Although with this method we can't use Serializers to validate input, we still several ways to validate the uploaded content.
One method is to restrict the content type (as reported by the client during the upload) to allowed types. For example, if our file upload is actually image upload, we can accept only image types. The default parser accepts any file type so we'll subclass it to restrict the accepted types:
class ImageUploadParser(FileUploadParser):
media_type = 'image/*'
class MyUploadView(APIView):
parser_class = (ImageUploadParser,)
...
However this relies on the client to not lie about the uploaded content type. If we want to make sure the file type is correct, we can do the inspection ourselves. In case of image files, we can do that easily using the Pillow package (used internaly by Django, so if you use image fields you're already using Pillow).
from PIL import Image
class MyUploadView(APIView):
parser_class = (ImageUploadParser,)
def put(self, request, format=None):
if 'file' not in request.data:
raise ParseError("Empty content")
f = request.data['file']
try:
img = Image.open(f)
img.verify()
except:
raise ParseError("Unsupported image type")
mymodel.my_file_field.save(f.name, f, save=True)
return Response(status=status.HTTP_201_CREATED)
User-friendly API
Why go to all this trouble?
Besides the aesthetical argument about it looking more RESTful, this approach also makes it easier for client authors to use your API. Whether in a mobile app, JavaScript in a browser, or from another backend language, your users will have an easier time.