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:

  1. 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.
  2. 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 but multipart/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 named x).
  3. 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:

  1. 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)
  2. 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.

Want to know more about how we can work together and launch a successful digital energy service?