September 20, 2019

Route: a consistent path helper for Django & DRF

Route: a consistent path helper for Django & DRF

I find one of the most frustrating parts of working with DRF is dealing with Django routing when combined with ViewSets and APIViews.  All those calls to as_view and SimpleRouter are a hassle, and make the URL hierarchy more difficult to see at a glance.  

The fact that include() syntax is confusing doesn't make it any easier

path('/', (include('api'),name), name)

So I decided to simplify this, for most of my use cases, and made a new route() helper.  It unifies the syntax for all of these, and handles ViewSets and APIViews just like any standard function, as well as removing the need for routers in most cases.

# this file will be included using include('apps.apiv3.urls') from the
# root urls.py, so app_name specifies the namespace
app_name = 'api-v3'
urlpatterns = [
    route('auth/', 'apps.auth.urls'),
    route('apps/', AppsViewSet, name='apps'),
    # the '/' is optional on this view
    route('account/?', AccountView, name='account'),
    route('products/', [
        route('list/', ProdListView, name='list'),
        route('add/', ProdAddView, name='add'),
        route('del/', prod_del_func, name='del'), # can pass a view function
    ], name='products')
]
  • Views, ViewSets & view functions, urls.py, etc are added in the same manner
  • Arrays of routes are handled directly, no confusing tuple(urls,name)
  • Same name= for creating namespaces or assigning the view's name
import re
from inspect import isclass

from django.urls import path as _django_path, include
from rest_framework.routers import SimpleRouter
from rest_framework.views import APIView
from rest_framework.viewsets import GenericViewSet


def route(prefix, routes, name: str = None, ignore_case=False):
    """
    Standardize how to add URLs in `urlpatterns=[]`
    :param prefix: URL Prefix, e.g. "admin/"
    :param routes: List, string, View, etc
    :param name: Optional namespace/name if you want to reverse()
    :param ignore_case: If the URL should ignore the case
    """

    def path(*args, **kwargs):
        """
        + Patch the URL to allow case-insensitivity
        + patch trailing `/?` at end works as expected
        - delete & function and adjust the `import` if not desired
        """
        p = _django_path(*args, **kwargs)
        p.pattern.regex = re.compile(
            p.pattern.regex.pattern.replace("\\?$", "?$"),
            re.I if ignore_case else re.U
        )
        return p

    if isinstance(routes, str):
        # include urls.pu route('path/', 'app.urls', 'namespace')
        return path(prefix, include((routes, name), name))

    elif isinstance(routes, list):
        # include a `list` of routes directly
        # route("prefix/", [ route("a/", ..), route("b/", ...) ])
        return path(prefix, include((routes, name)), name=name)

    elif isinstance(routes, tuple) and len(routes) == 3:
        # handle include('app.urls') style tuple 
        # preferred to use route('p/', 'app.urls') instead
        # but required for `route('admin/', admin.site.urls)`
        if name is not None:
            raise Exception("Name is required.")
        return path(prefix, routes)

    elif isclass(routes):
        # GenericViewSet inherits from APIView, so process them first
        if issubclass(routes, GenericViewSet):
            router = SimpleRouter()
            router.register("", routes, basename=name)
            return path(prefix, include(router.urls), name=name)
        elif issubclass(routes, APIView):
            return path(prefix, routes.as_view(), name=name)
        else:
            raise Exception(f"Route was a {type(routes).__name__}. "
                             "Must be GenericViewSet or APIView")

    elif callable(routes):
        # handle standard view functions
        return path(prefix, routes, name=name)

    else:
        raise Exception("Invalid route: {routes}")

Note: This does not support kwargs for the as_view() calls;  I believe the view should describe itself, but support should be easy to patch in if its needed.  

There are two non-essential additions:

  • Allow ignore_case to set case-insensitive on the url fragment
  • Allow an optional / at the end of urls, via route("path/?").  

I have found that these two were the only modifications I needed in my code, so I added them here and never used re_path().  Its ugly anyway, and url parameters should be handled via custom converters, instead:

class SizeConverter(StringConverter): 
    # use base to_python/to_url
    regex = "(S|M|L|XL|XXL)"

register_converter(SizeConverter, "size")

# use it in the URL
route("shoes/<size:shoesize>", MyView, "shoes")
    
class MyView(APIView):
    def get(self, request, shoesize):
        ...