Building a Remote Procedural Call (RPC) Endpoint With the Django Rest Framework

The Django Rest Framework (DRF), has a lot of built in functionality that supports CRUD operations, but building an RPC endpoint requires hand-rolling much of that. Ultimately, if we’re adding an RPC endpoint to an existing API with mostly REST endpoints, we want to match the design of our new endpoint to match that of the DRF, so we need to understand what each piece does.

All of these pieces are explained in the DRF docs, but seeing a complete example and how they all work together can add clarity.

I’m going to use the example here of fulfilling a purchase. In this example, I do have a Purchase database object that will be modified in the process. However, because other things happen too — the user is emailed a receipt, other database objects are created or modified (a course registration is created, for example), etc., this deviates from a typical REST endpoint.

So before we jump in, here’s the spoiler of what’s going to happen, and links to the relevant docs:

  • We’ve got some routes, which create the urls, and point to a class based view that has several methods on it, each corresponding to an endpoint
  • The view has a custom mixin, which mimics DRF’s built-in mixins
  • One of these endpoints is a custom detail route, which will call super and hit the mixin
  • The mixin will do some stuff to validate & prepare the data, and call the serializer, and send a response
  • The serializer will do the main logic, and prepare the data for the mixin to respond with

Let’s look at each of these in more detail.

First, we need to register the urls that our users can call. My urls.py file looks like this:

from rest_framework.routers import SimpleRouter
from api.v1.purchases.views import Purchase

router = SimpleRouter(trailing_slash=False)
router.register(r’’, Purchase, base_name=’purchase’)
urlpatterns = router.urls

In this case, the Purchase that I’m registering as the view to call, is referring to a class based view, which is defined and described in more detailed below, and is different from the Purchase model.

This means that there will actually be an endpoint available for each of the methods defined in that class. This urls file is included in my general api/v1 urls file.

The class described below contains both my REST endpoints, which use the ListModelMixin, RetrieveModelMixin, etc., that are defined by DRF. I’ve included my custom PurchaseFulfillmentMixin, that will do similar work for that endpoint.

from rest_framework import mixins
from rest_framework.decorators import detail_route

class Purchase(mixins.ListModelMixin,                          
               mixins.RetrieveModelMixin,
               mixins.UpdateModelMixin,
               PurchaseFulfillmentMixin):    
    lookup_field = 'purchase_id'  
    lookup_value_regex = '[0-9a-z]+'    
    serializer_class = PurchaseSerializer    
    def get_serializer_class(self):        
        if self.action == 'fulfill':            
            return PurchaseFulfillmentSerializer        
        return self.serializer_class    
    def get_queryset(self): 
        # You have access to self.request here
        ...
    # This class also included methods for each of the basic CRUD operations, which take advantage of ListModelMixin, etc.
    @detail_route(methods=['post']
    def fulfill(self, request, *args, **kwargs):
        return super(Purchase, self).fulfill(request, *args, **kwargs)

Defining get_serializer_class allows me to have a default serializer that is used for all of my CRUD operations, while defining my own custom one for my RPC endpoint, which is returned if the fulfill endpoint is called.

Using a detail_route allows me to include an RPC endpoint within the existing framework of my CRUD ones. We’re matching the structure! Calling it a detail_route means that the URL generated by this endpoint expects the primary key of the instance to be included (i.e. purchases//fulfill), which means I can call get_object, and have it work (more on that in the next section).

The fulfill view just calls super, and will find the fulfill method on the customPurchaseFulfillmentMixin, explained next, and we’re on our way!

My custom mixin mimics the behavior in the other mixins included in my view class. Looking at the DRF source code for the built-in mixins, you’ll see they are primarily responsible for:

  • Get the object or queryset to be returned or modified(depending on whether it’s a list or detail endpoint)
  • Get and validate the serializer
  • Have the serializer do the thing
  • Return a Response

So without further ado, my custom mixin:

from django.core.exceptions import PermissionDenied
from rest_framework.response import Response

class PurchaseFulfillmentMixin(object): 
    def fulfill(self, request, *args, **kwargs): 
        instance = self.get_object()
        serializer = self.get_serializer(instance, data=request.data)
        serializer.is_valid(raise_exception=True)          
        serializer.fulfill(instance, *args, **kwargs)
        return Response(serializer.data)
  • Because this was defined as a detail_route and my url includes a primary key, I can call get_object, which first calls my self-defined get_queryset method, and then looks within that for the object with the primary key from the path.
  • If you want to do any validation on the instance, this would be the place to do it. For example, if you wanted to confirm that the purchase in question hadn’t already been fulfilled (idempotency FTW!), this is where you would do that.
  • Get the serializer, and initialize it with the object in question, and the data. This gives me access to validated_data, as noted below.
  • Making sure the serializer is valid. This makes sure fields are in the proper format, and if you’ve added any custom validators to your fields (not discussed, but if you’re curious, docs are here). In my case, the closest thing I have to a custom validator is that one of my fields is a ChoiceField. This means that if someone calls my endpoint with a value in the state field that wasn’t included in the options, the serializer will fail validation and because I’ve called it with raise_exception=True, a 400 response will be returned.
  • Calls the fulfill method, as defined on the serializer. More on that below, but that’s where most of the logic is, that actually does the fulfilling.
  • Returns a response, based on what the fulfill method returns, and the state of the serializer’s data at the end of that call.

Serializers allow you to define the fields that are available to your users in both the request and the response, and holds most of the logic. By default, fields have both read and write access. In my case, I wanted very different fields in my request and response, so all of my fields have either read_only=True (will be included in the response, but not accessible to the user via the request), or write_only=True (will be accessible via the POST data in the request, but will not be included in the response).

from rest_framework import serializers, fields
FULFILLMENT_STATE_CHOICES = ...

class PurchaseSerializer(serializers.ModelSerializer):
    ...
    class Meta:        
        model = Purchase # the database object, not the class-based view
        ...

class PurchaseFulfillmentSerializer(serializers.Serializer):     
    purchase_state = fields.ChoiceField(choices=FULFILLMENT_STATE_CHOICES, write_only=True)    
    redirect_url = fields.SerializerMethodField()    
    error_msg = fields.CharField(required=False, write_only=True)
    purchase = PurchaseSerializer(source='*', read_only=True)

    def get_redirect_url(self, purchase):  
        ...

    def fulfill(self, instance, *args, **kwargs):        
        # Do the thing
        return instance

In addition to being used for the CRUD endpoints, as discussed in the view section, the PurchaseSerializer is used for a field in the PurchaseFulfillmentSerializer, and is a standard ModelSerializer, so not discussed in detail in this post.

At this point, the main thing left is to do the actual logic of fulfilling. I’ve not included this, since it’s not relevant to the design of this endpoint, so a few notes:

  • You have access to self.validated_data within your serializer, which means you can, of course, make decisions within your functions based on what the user has passed in.
  • SerializerMethodFields are somewhat beyond the scope of this post, but basically allow you to define a field which will then look for a method by the name get_<field_name> and will use the return value of that function as the value for the field. Docs are here.
  • Because this is not a standard CRUD call and I am not calling any super methods, if you want to make changes to the instance that persist in the database, you’ll need to call instance.save()

Note: I include imports for things external to my project in code snippets to help others who might use them as an example know where to look, but don’t include imports from within my project as they aren’t relevant — it is implied that everything is properly imported.