Going full JSON in an API has a couple of benefits over working with urlencoded form data (shared input and output formats, easier to work with complex data hierarchies, less verbose, etc). There are, however, a couple of pitfalls. Working with file uploads in JSON APIs is one of those things that often cause confusion and can lead to bikeshedding.
There are currently three commonly employed methods to mix JSON and binary data:
- Encode the binary data (e.g. Base64) and pass it as a value of a key in the JSON document. There are a couple of drawbacks with this approach: it increases the size of the uploaded files, reduces API output readability, prevents servers from streaming files to disk, and the encoding/decoding adds processing overhead.
multipart/form-data
can be used to handle multiple documents in a single request. While easy to design and implement, this solution introduces different content-types for very similar endpoints (i.e.application/json
for/profiles
butmultipart/form-data
for/posts
if the latter accepts a cover photo) and handling the JSON-encoded part of the body requires some documentation (i.e. specifying that the JSON-encoded data should be sent in a form field namedx
).- Split the functionality into two or more separate requests to handle resources that are made up of a mix of binary and JSON content. This approach may force you to rethink your structure a little bit, creation isn't "atomic", and it will require a little bit of work (e.g. prune unused metadata or uploaded files, multiple serializers, additional endpoints).
We generally prefer option three despite the drawbacks. The approach can be applied in a couple of different ways:
- Only accept URLs in the API and handle uploads separately (either custom sub-system or an external service such as S3, Azure Storage, or GCS)
- Create the resource with the metadata first and then add the binary content in a separate request (or the other way around)
The first approach is quite self-explanatory, so we'll describe the second approach in this post.
Assuming you have DRF up and running in your project, start with a simple model in models.py
:
from django.db import models
class Profile(models.Model):
name = models.CharField(max_length=200)
bio = models.TextField(blank=True)
pic = models.ImageField(upload_to='pics', blank=True)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
Then create two separate serializers in serializers.py
-- one deal with creating new objects and retrieving data, and one to handle the file:
from rest_framework import serializers
from .models import Profile
class ProfileSerializer(serializers.ModelSerializer):
class Meta:
model = Profile
fields = ['name', 'bio', 'pic']
read_only_fields = ['pic']
class ProfilePicSerializer(serializers.ModelSerializer):
class Meta:
model = Profile
fields = ['pic']
And extend a ModelViewSet
in views.py
that has a special method named pic
decorated with action
that handles uploads:
from rest_framework import parsers
from rest_framework import response
from rest_framework import status
from rest_framework import viewsets
from .models import Profile
from .serializers import ProfilePicSerializer
from .serializers import ProfileSerializer
class ProfileViewSet(viewsets.ModelViewSet):
serializer_class = ProfileSerializer
queryset = Profile.objects.all()
@decorators.action(
detail=True,
methods=['PUT'],
serializer_class=ProfilePicSerializer,
parser_classes=[parsers.MultiPartParser],
)
def pic(self, request, pk):
obj = self.get_object()
serializer = self.serializer_class(obj, data=request.data,
partial=True)
if serializer.is_valid():
serializer.save()
return response.Response(serializer.data)
return response.Response(serializer.errors,
status.HTTP_400_BAD_REQUEST)
Finally, register the routes in urls.py
:
from django.urls import include, path
from rest_framework import routers
import views
router = routers.DefaultRouter()
router.register('profiles', views.ProfileViewSet)
urlpatterns = [
path('api/v1/', include(router.urls)),
]
You can now create a profile with curl by making a POST
request to /api/v1/profiles/
:
curl -d '{"name": "Douglas Hofstadter"}' \\
-H "Content-Type: application/json" \\
-X POST <http://127.0.0.1:8000/api/v1/profiles/>
And attach an image by making a PUT
request to /api/v1/profiles/1/pic/
:
curl -F "pic=@pic.png" \\
-X PUT <http://127.0.0.1:8000/api/v1/profiles/1/pic/>
It's a little bit of extra work, and it might -- depending on your data layout -- require you to prune resources that do not have their binary assets attached within some period of time, but it is quite pleasant to document and work with.