Search code examples
pythondjangovue.jsroutestrailing-slash

Django routing with VueJS single page has unexpected behaviour when there is no trailing slash in the URL


I have a Django backend, VueJS frontend combination, where I serve a REST API via Django and a single page application with VueJS and vue-router.

From this question I got the tip to use the following urls in my main urls.py:

urlpatterns = [
    re_path(r'^(?P<filename>(robots.txt)|(humans.txt))$', views.home_files, name='home-files'),
    path('api/', include('backend.urls', namespace='api')),
    path('auth/', include('auth.urls')),
    path('admin/', admin.site.urls),
    re_path(r'^.*$', views.vue), # VueJS frontend
]

So I want URLs to behave like this:

{baseDomain}/api/users/1/ -> go to backend.urls  
{baseDomain}/auth/login/ -> go to auth.urls  
{baseDomain}/admin/ -> go to admin page  
{baseDomain}/de/home -> vue-router takes over

Now these URLs work perfectly fine, however I would expect that {baseDomain}/api/users/1 (without trailing slash) would still go to backend.urls, however what happens is that I land on the Vue page.

Adding APPEND_SLASH = True in settings.py does not help either, since it only appends a slash if it didn't find a page to load. But since the regex for my frontend matches anything it always redirects to Vue.

My attempt was to fix it by adding:

re_path(r'.*(?<!/)$', views.redirect_with_slash)

with the following code:

def redirect_with_slash(request):
    '''Redirects a requested url with a slash at the end'''
    if request.path == '/':
        return render(request, 'frontend/index.html')
    return redirect(request.path + '/')

But it isn't a very elegant one. Also mind the if request.path == '/':. Weirdly enough, Django would match '/' with the regex r'.*(?<!/)$' and then redirect to '//', which is an invalid URL and show an error page, so I had to include this if-statement.

Does anyone have a solution for this? In the referenced question this did not seem to be an issue, so I wonder why it is in my project.

EDIT: backend urls.py

"""
backend urls.py
"""
from django.urls import include, path
from rest_framework_nested import routers
from auth.views import UserViewSet, GroupViewSet, ProjectViewSet
from .views import IfcViewSet, IfcFileViewSet

app_name = 'api'

router = routers.DefaultRouter() #pylint: disable=C0103
router.register(r'users', UserViewSet)
router.register(r'groups', GroupViewSet)
router.register(r'projects', ProjectViewSet)
projects_router = routers.NestedSimpleRouter(router, r'projects', lookup='project')
projects_router.register(r'models', IfcFileViewSet, base_name='projects-models')

urlpatterns = [
    path('', include(router.urls)),
    path('', include(projects_router.urls))
]

"""
auth urls.py
"""
from django.urls import path, include
from rest_framework import routers
from rest_framework_jwt.views import obtain_jwt_token, refresh_jwt_token
from .views import RegistrationViewSet

app_name = 'authentication'
router = routers.DefaultRouter()
router.register('register', RegistrationViewSet)

urlpatterns = [
    path('', include(router.urls)),
    path('', include('rest_auth.urls')),
    path('refresh_token/', refresh_jwt_token),
]

Solution

  • The problem is that you have a catch-all in re_path(r'^.*$', views.vue), so if any URL is not matched exactly on the earlier paths, this will be triggered.

    Django's CommonMiddleware actually appends a trailing slash and redirect, when it finds a 404 and the URL path does not end in / (depending on the APPEND_SLASH setting), but that's on response.

    In you case, you can have a tiny request middleware that appends trailing slash if the request path not end in / e.g.:

    from django.shortcuts import redirect
    
    class AppendTrailingSlashOnRequestMiddleware:
    
        def __init__(self, get_response):
            self.get_response = get_response
    
        def __call__(self, request):
    
            if not request.path.endswith('/'):
                query_string = request.META['QUERY_STRING']
                query_string = f'?{query_string}' if query_string else ''
                to_url = f'{request.path}/{query_string}'
                return redirect(to_url, permanent=True)
    
            response = self.get_response(request)
    
            return response
    

    Add the middleware to settings.MIDDLEWARE obviously, preferably put it at the top to prevent unnecessarily processing from other middlewares as we'll be redirecting anyways and processing would be required then as well.


    But this has an issue; the data from POST/PUT/PATCH will be lost when doing redirection (here we're doing 301 but similarly applicable for 302. There's Temporary Redirect 307 that can help us in this regard and the good thing is all the regular browsers including IE support this. But Django does not have this out of the box; so we need to implement this ourselves:

    from django.http.response import HttpResponseRedirectBase
    
    class HttpTemporaryResponseRedirect(HttpResponseRedirectBase):
        status_code = 307
    

    Now, import that in the middleware, and use it instead of redirect:

    class AppendTrailingSlashOnRequestMiddleware:
    
        def __init__(self, get_response):
            self.get_response = get_response
    
        def __call__(self, request):
    
            if not request.path.endswith('/'):
                query_string = request.META['QUERY_STRING']
                query_string = f'?{query_string}' if query_string else ''
                to_url = f'{request.path}/{query_string}'
                return HttpTemporaryResponseRedirect(to_url)  # here
    
            response = self.get_response(request)
    
            return response
    

    N.B: If you want to keep the browser caching facilities for GET, you can redirect to 301/307 based on request.method.