This content originally appeared on DEV Community and was authored by Tyler Smith
I don't love Django's approach to request handling or routing. The framework has too many options and too few opinions. Conversely, frameworks like Ruby on Rails provide standardized conventions for request handling and routing via its Action Controller and resource routing.
This post will extend Django REST Framework's ViewSet
and SimpleRouter
to provide a Rails-like request handler class + resource routing in server-render Django applications. It also features form-level method spoofing for PUT
, PATCH
and DELETE
requests via custom middleware.
The problem with Django's routing
For request handling, Django offers function-based views, generic class-based views, and model class-based views. Django's class-based views embody the worst aspects of object-oriented programming, obfuscating the control flow while requiring considerably more code than their function-based counter parts.
Similarly, the framework does not offer recommendations or conventions for URL path structure. For comparison, here is the convention for a Ruby on Rails resource:
HTTP Verb | Path | Controller#Action | Used for |
---|---|---|---|
GET | /posts | posts#index | list of all posts |
GET | /posts/new | posts#new | form for creating a new post |
POST | /posts | posts#create | create a new post |
GET | /posts/:id | posts#show | display a specific post |
GET | /posts/:id/edit | posts#edit | form for editing a post |
PATCH/PUT | /posts/:id | posts#update | update a specific post |
DELETE | /posts/:id | posts#destroy | delete a specific post |
Because of the framework's conventions, each Ruby on Rails app is structured similarly and new developers can onboard quickly. By comparison, Django's laissez-faire approach culminates in substantial bikeshedding.
In the absence of framework-enforced conventions for views and URL structures, each Django app becomes a snowflake that takes a different approach. Worse yet, a single app may take several disparate approaches to views and URLs with no discernible rhyme or reason. I've seen it. I've lived it.
But the Django ecosystem already has alternate approaches that are similar to Rails.
Django REST Framework's routing
Unlike Django itself, Django REST Framework has strong routing conventions. Its ViewSet
class and SimpleRouter
enforce the following conventions:
HTTP Verb | Path | ViewSet.Action | Used for |
---|---|---|---|
GET | /posts/ | PostsViewset.list | list of all posts |
POST | /posts/ | PostsViewset.create | create a new post |
GET | /posts/:id/ | PostsViewset.retrieve | return a specific post |
PUT | /posts/:id/ | PostsViewset.update | update a specific post |
PATCH | /posts/:id/ | PostsViewset.partial_update | update part of a specific post |
DELETE | /posts/:id/ | PostsViewset.destroy | delete a specific post |
Unfortunately, this only works with API routes. It does not work with Django server-rendered applications. This is because native browser forms can only implement GET
and POST
requests. Ruby on Rails uses a hidden input in their forms to get around this limitation:
<form method="POST" action="/books">
<input name="title" type="text" value="My book" />
<input type="submit" />
<!-- Here's the magic part: -->
<input name="_method" type="hidden" value="put" />
</form>
When submitted via POST
request, Ruby on Rails will magically change the request's method to PUT
in the backend. Django has no such feature.
We can leverage Django REST Framework's features to implement Rails-like request handling and resource routing in Django, and build our own middleware for overriding the request method. This way we can get a similar experience in server-rendered apps that use Django templates.
Plan of action
Because Django REST Framework's ViewSet
and SimpleRouter
classes provide much of the Rails-like experience that we wish to emulate, we'll use those as the basis of our implementation. Here is the routing structure that we will build:
HTTP Verb | Path | ViewSet.Action | Used for |
---|---|---|---|
GET | /posts/ | PostsViewset.list | list of all posts |
GET | /posts/create/ | PostsViewset.create | form for creating a new post |
POST | /posts/create/ | PostsViewset.create | create a new post |
GET | /posts/:id/ | PostsViewset.retrieve | return a specific post |
GET | /posts/:id/update/ | PostsViewset.update | form for editing a post |
PUT | /posts/:id/update/ | PostsViewset.update | update a specific post |
DELETE | /posts/:id/ | PostsViewset.destroy | delete a specific post |
The routes in bold are ones that differ from what Django REST Framework's SimpleRouter
provides out-of-the-box.
To build this Rails-like experience, we must do the following:
- Subclass REST Framework's
ViewSet
and provide it with defaults appropriate for server-rendered Django templates. - Subclass REST Framework's
SimpleRouter
and create new routes for the create and update methods. - Create a middleware that can change the request verb based on a hidden input named
_method
.
Prep work
We need to do a little bit of setup before we're ready to implement our routing. First, install Django REST Framework by running the following command in your main project directory:
pip install djangorestframework
Then, add REST Framework to the INSTALLED_APPS
list in settings.py
:
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
# Add this:
"rest_framework",
]
Next, we need a place to store our subclasses and custom middleware. Create an overrides
directory in the main project directory with the following files:
overrides/
├── __init__.py
├── middleware.py
├── routers.py
└── viewsets.py
With that, we're ready to code.
Subclassing ViewSet
for template rendering
Place the following code in overrides/viewsets.py
:
from rest_framework.authentication import SessionAuthentication
from rest_framework.parsers import FormParser
from rest_framework.renderers import TemplateHTMLRenderer
from rest_framework.viewsets import ViewSet
class TemplateViewSet(ViewSet):
authentication_classes = [SessionAuthentication]
parser_classes = [FormParser]
renderer_classes = [TemplateHTMLRenderer]
Our future ViewSets will be subclassed from this TemplateViewSet
, and it will serve the same purpose as a Rails Action Controller. It uses the TemplateHTMLRenderer
so that it renders HTML by default, the FormParser
to parse form submissions, and SessionAuthentication
to authenticate the user. It's nice that Django REST Framework includes these, allowing us to leverage DRF for traditional server-rendered web apps.
Subclassing SimpleRouter
The router class is what will enable us to send requests to the appropriate ViewSet method. By default, REST Framework's simple router uses POST /:resource/
to create a new resource, and PUT /:resource/:id/
to update a resource.
We must modify the create and update routes. Unlike Rails or Laravel, Django has no way to pass form errors to a redirected route. Because of this, a page containing a form to create or update a resource must post the form data to its own URL.
We will use the following routes for creating and updating resources:
-
/:resource/create/
for creating a new resource. -
/:resource/:id/update/
for updating a resource.
Django REST Framework's SimpleRouter
has a routes
list that associates the routes with the methods of the ViewSet
(source code). We will subclass SimpleRouter
and override its routes
list, moving the create and update methods to their own routes with our desired paths.
Add the following to overrides/routers.py
:
from rest_framework.routers import SimpleRouter, Route, DynamicRoute
class TemplateRouter(SimpleRouter):
routes = [
Route(
url=r"^{prefix}{trailing_slash}$",
mapping={"get": "list"},
name="{basename}-list",
detail=False,
initkwargs={"suffix": "List"},
),
# NEW: move "create" from the route above to its own route.
Route(
url=r"^{prefix}/create{trailing_slash}$",
mapping={"get": "create", "post": "create"},
name="{basename}-create",
detail=False,
initkwargs={},
),
DynamicRoute(
url=r"^{prefix}/{url_path}{trailing_slash}$",
name="{basename}-{url_name}",
detail=False,
initkwargs={},
),
Route(
url=r"^{prefix}/{lookup}{trailing_slash}$",
mapping={"get": "retrieve", "delete": "destroy"},
name="{basename}-detail",
detail=True,
initkwargs={"suffix": "Instance"},
),
# NEW: move "update" from the route above to its own route.
Route(
url=r"^{prefix}/{lookup}/update{trailing_slash}$",
mapping={"get": "update", "put": "update"},
name="{basename}-update",
detail=True,
initkwargs={},
),
DynamicRoute(
url=r"^{prefix}/{lookup}/{url_path}{trailing_slash}$",
name="{basename}-{url_name}",
detail=True,
initkwargs={},
),
]
Overriding the HTTP method in middleware
Place the following code in overrides/middleware.py
:
from django.conf import settings
class FormMethodOverrideMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.method == "POST":
desired_method = request.POST.get("_method", "").upper()
if desired_method in ("PUT", "PATCH", "DELETE"):
token = request.POST.get("csrfmiddlewaretoken", "")
# Override request method.
request.method = desired_method
# Hack to make CSRF validation pass.
request.META[settings.CSRF_HEADER_NAME] = token
return self.get_response(request)
If an incoming request contains a form field named _method
with a value of PUT
, PATCH
, or DELETE
, this middleware will override the request's method with its value. This allows forms to emulate other HTTP methods and have their submissions routed to the appropriate request handler.
The interesting bit of this code is the CSRF token hack. Django's middleware only checks for the csrfmiddlewaretoken
form field on POST
requests. However, it checks for a CSRF token on all requests with methods not defined as "safe" (any request that's not GET
, HEAD
, OPTIONS
, or TRACE
).
PUT
, PATCH
and DELETE
requests are available through JavaScript and HTTP clients. Django expects these requests to use a CSRF token header like X-CSRFToken
. Because Django will not check the the csrfmiddlewaretoken
form field in non-POST
requests, we must place the token in the header where the CSRF middleware will look for it.
Now that we've completed our middleware, add it to the MIDDLEWARE
list in settings.py
:
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
# Add this:
"overrides.middleware.FormMethodOverrideMiddleware"
]
Using the ViewSet and Router
Let's say that we have a blog app within our Django project. Here is what the BlogPostViewSet
would look like:
# blog/views.py
from blog.forms import BlogPostForm
from blog.models import BlogPost
from django.shortcuts import get_object_or_404, render, redirect
from overrides.viewsets import TemplateViewSet
class BlogPostViewSet(TemplateViewSet):
def list(self, request):
return render(request, "blog/list.html", {
"posts": BlogPost.objects.all()
})
def retrieve(self, request, pk):
post = get_object_or_404(BlogPost, id=pk)
return render(request, "blog/retrieve.html", {"post": post})
def create(self, request):
if request.method == "POST":
form = BlogPostForm(request.POST)
if form.is_valid():
post = form.save()
return redirect(f"/posts/{post.id}/")
else:
form = BlogPostForm()
return render(request, "blog/create.html", {"form": form})
def update(self, request, pk):
post = BlogPost.objects.get(id=pk)
if request.method == "PUT":
form = BlogPostForm(request.POST, instance=post)
if form.is_valid():
post = form.save()
return redirect(f"/posts/{post.id}/")
else:
form = BlogPostForm(instance=post)
return render(request, "blog/update.html", {
"form": form, "post": post
})
def destroy(self, request, pk):
website = BlogPost.objects.get(id=pk)
website.delete()
return redirect(f"/posts/")
Here is how we would add these URLs to the project's urlpatterns
list using the TemplateRouter
that we created:
# project_name/urls.py
from blog.views import BlogPostViewSet
from django.contrib import admin
from django.urls import path
from overrides.routers import TemplateRouter
urlpatterns = [
path("admin/", admin.site.urls),
# other routes...
]
router = TemplateRouter()
router.register(r"posts", BlogPostViewSet, basename="post")
urlpatterns += router.urls
Finally, ensure that the forms within your Django templates have both the CSRF token and hidden _method
field. Here's an example from the update post form:
<form method="POST" action="/posts/{{ post.id }}/update/">
{% csrf_token %}
{{ form }}
<input type="hidden" name="_method" value="PUT" />
<button type="submit">Submit</button>
</form>
And that's it. You now have Rails or Laravel-like controllers in your Django application.
Should you actually consider doing this?
Maybe. The advantage of this approach is that it removes a lot of opportunities for bikeshedding if your app follows REST-like conventions. If you've ever seen Adam Wathan's Cruddy by Design talk, you know that following REST-like conventions can get you pretty far.
But it's a slightly awkward fit. Rails and Laravel controllers have separate endpoints for displaying a form vs committing a resource to the database, giving them the appearance of having further "separation of concerns" than one can achieve with Django.
The ViewSet model is also tightly coupled with the idea of a "resource." Using the TemplateViewSet
would be an awkward fit for a homepage or contact page because the page would be forced to use the list
method (though this could be renamed to index
on the TemplateRouter
). In these cases you'd be tempted to use a function-based view, which reintroduces the opportunity for bikeshedding.
Finally, the automatically generated URL paths make it harder to understand what routes the application has at a glance without using tools like django-extensions.
All of that said, this approach offers something that Django does not: strong conventions with fewer opportunities for bikeshedding. If that's appealing to you, then this might be worth a shot.
This content originally appeared on DEV Community and was authored by Tyler Smith
Tyler Smith | Sciencx (2024-07-17T02:47:09+00:00) Emulating Rails-like resource controllers in a server-rendered Django app. Retrieved from https://www.scien.cx/2024/07/17/emulating-rails-like-resource-controllers-in-a-server-rendered-django-app/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.