Skip to content

Redirects: new type to support URLs without trailing slash #12323

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions readthedocs/api/v3/tests/test_redirects.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from readthedocs.redirects.constants import (
CLEAN_URL_TO_HTML_REDIRECT,
CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT,
EXACT_REDIRECT,
HTML_TO_CLEAN_URL_REDIRECT,
)
Expand Down Expand Up @@ -172,6 +173,30 @@ def test_projects_redirects_type_clean_url_to_html_list_post(self):
self.assertEqual(redirect.from_url, "")
self.assertEqual(redirect.to_url, "")

def test_projects_redirects_type_clean_url_without_trailing_slash_to_html_list_post(self):
self.assertEqual(Redirect.objects.count(), 1)
data = {
"type": CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT,
}

self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}")
response = self.client.post(
reverse(
"projects-redirects-list",
kwargs={
"parent_lookup_project__slug": self.project.slug,
},
),
data,
)
self.assertEqual(response.status_code, 201)
self.assertEqual(Redirect.objects.all().count(), 2)

redirect = Redirect.objects.first()
self.assertEqual(redirect.redirect_type, CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT)
self.assertEqual(redirect.from_url, "")
self.assertEqual(redirect.to_url, "")

def test_projects_redirects_detail_put(self):
url = reverse(
"projects-redirects-detail",
Expand Down Expand Up @@ -347,6 +372,19 @@ def test_projects_redirects_validations(self):
["Only one redirect of type `clean_url_to_html` is allowed per project."],
)

get(Redirect, redirect_type=CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT, project=self.project)
response = self.client.post(
url,
data={
"type": CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT,
},
)
self.assertEqual(response.status_code, 400)
self.assertEqual(
response.json(),
["Only one redirect of type `clean_url_without_trailing_slash_to_html` is allowed per project."],
)

get(Redirect, redirect_type=HTML_TO_CLEAN_URL_REDIRECT, project=self.project)
response = self.client.post(
url,
Expand Down
17 changes: 17 additions & 0 deletions readthedocs/proxito/tests/test_old_redirects.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from readthedocs.projects.constants import SINGLE_VERSION_WITHOUT_TRANSLATIONS
from readthedocs.projects.models import Feature
from readthedocs.redirects.constants import (
CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT,
CLEAN_URL_TO_HTML_REDIRECT,
EXACT_REDIRECT,
HTML_TO_CLEAN_URL_REDIRECT,
Expand Down Expand Up @@ -1241,6 +1242,22 @@ def test_redirect_html_index(self):
"http://project.dev.readthedocs.io/en/latest/faq.html",
)

def test_redirect_html_without_trailing_slash(self):
fixture.get(
Redirect,
project=self.project,
redirect_type=CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT,
force=True,
)
r = self.client.get(
"/en/latest/guides/faq", headers={"host": "project.dev.readthedocs.io"}
)
self.assertEqual(r.status_code, 302)
self.assertEqual(
r["Location"],
"http://project.dev.readthedocs.io/en/latest/guides/faq.html",
)

def test_redirect_htmldir(self):
fixture.get(
Redirect,
Expand Down
5 changes: 5 additions & 0 deletions readthedocs/redirects/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,16 @@
PAGE_REDIRECT = "page"
EXACT_REDIRECT = "exact"
CLEAN_URL_TO_HTML_REDIRECT = "clean_url_to_html"
CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT = "clean_url_without_trailing_slash_to_html"
HTML_TO_CLEAN_URL_REDIRECT = "html_to_clean_url"

TYPE_CHOICES = (
(PAGE_REDIRECT, _("Page Redirect")),
(EXACT_REDIRECT, _("Exact Redirect")),
(CLEAN_URL_TO_HTML_REDIRECT, _("Clean URL to HTML (file/ to file.html)")),
(
CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT,
_("Clean URL, without trailing slash, to HTML (file to file.html)"),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that users who want this, want the other behavior (Clean URLs in this case is `/foo) -- so this feels like an even weirder default thing to support. It seems like we should just tell them how to configure their tools to output the right URLs.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Clean URLs" is a weird definition and I don't think there is an standard for it 😄

In the Sphinx world it means using /page/ instead of page.html. For Vercel, it means the URL won't have the extension of the filename, and it could be with or without the trailing slash. They differentiate these two things explicitly.

Copy link
Member

@ericholscher ericholscher Jul 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea but it always means without the .html extension :) hard to explain without a bunch of text tho.

),
(HTML_TO_CLEAN_URL_REDIRECT, _("HTML to clean URL (file.html to file/)")),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Generated by Django 5.2.4 on 2025-07-15 13:25

from django.db import migrations
from django.db import models
from django_safemigrate import Safe


class Migration(migrations.Migration):
safe = Safe.before_deploy()
dependencies = [
("redirects", "0009_remove_status_field"),
]

operations = [
migrations.AlterField(
model_name="redirect",
name="redirect_type",
field=models.CharField(
choices=[
("page", "Page Redirect"),
("exact", "Exact Redirect"),
("clean_url_to_html", "Clean URL to HTML (file/ to file.html)"),
(
"clean_url_without_trailing_slash_to_html",
"Clean URL, without trailing slash, to HTML (file to file.html)",
),
("html_to_clean_url", "HTML to clean URL (file.html to file/)"),
],
help_text="The type of redirect you wish to use.",
max_length=255,
verbose_name="Redirect Type",
),
),
]
17 changes: 17 additions & 0 deletions readthedocs/redirects/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Django models for the redirects app."""

import os
import re

import structlog
Expand All @@ -11,6 +12,7 @@
from readthedocs.projects.models import Project
from readthedocs.projects.ordering import ProjectItemPositionManager
from readthedocs.redirects.constants import CLEAN_URL_TO_HTML_REDIRECT
from readthedocs.redirects.constants import CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT
from readthedocs.redirects.constants import EXACT_REDIRECT
from readthedocs.redirects.constants import HTML_TO_CLEAN_URL_REDIRECT
from readthedocs.redirects.constants import HTTP_STATUS_CHOICES
Expand Down Expand Up @@ -134,6 +136,7 @@ def save(self, *args, **kwargs):
self.from_url_without_rest = None
if self.redirect_type in [
CLEAN_URL_TO_HTML_REDIRECT,
CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT,
HTML_TO_CLEAN_URL_REDIRECT,
]:
# These redirects don't make use of the ``from_url``/``to_url`` fields.
Expand Down Expand Up @@ -324,6 +327,20 @@ def redirect_clean_url_to_html(self, filename, path, language=None, version_slug
allow_crossdomain=False,
)

def redirect_clean_url_without_trailing_slash_to_html(
self, filename, path, language=None, version_slug=None
):
log.debug("Redirecting...", redirect=self)
name, ext = os.path.splitext(filename)
if not ext:
to = name + ".html"
return self.get_full_path(
Comment on lines +335 to +337
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still feels weird to me. It will redirect /foo to /foo.html, even if /foo.html doesn't exist. I thought the other implementations here only redirected if this file existed? It seems OK, but very unexpected as a user and maintainer :/

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's how redirect works, right? If the user defines any redirect we don't check the to URL exists before redirecting. However, we do check if the from URL exists before redirecting, unless force=True is defined.

https://github.com/readthedocs/readthedocs.org/blob/main/readthedocs/redirects/models.py#L310-L335

filename=to,
language=language,
version_slug=version_slug,
allow_crossdomain=False,
)

def redirect_html_to_clean_url(self, filename, path, language=None, version_slug=None):
log.debug("Redirecting...", redirect=self)
to = filename.removesuffix(".html") + "/"
Expand Down
6 changes: 5 additions & 1 deletion readthedocs/redirects/querysets.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from readthedocs.core.permissions import AdminPermission
from readthedocs.core.querysets import NoReprQuerySet
from readthedocs.redirects.constants import CLEAN_URL_TO_HTML_REDIRECT
from readthedocs.redirects.constants import CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT
from readthedocs.redirects.constants import EXACT_REDIRECT
from readthedocs.redirects.constants import HTML_TO_CLEAN_URL_REDIRECT
from readthedocs.redirects.constants import PAGE_REDIRECT
Expand Down Expand Up @@ -110,6 +111,9 @@ def get_matching_redirect_with_path(
path__startswith=F("from_url_without_rest"),
)
clean_url_to_html = Q(redirect_type=CLEAN_URL_TO_HTML_REDIRECT)
clean_url_without_trailing_slash_to_html = Q(
redirect_type=CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT
)
html_to_clean_url = Q(redirect_type=HTML_TO_CLEAN_URL_REDIRECT)

if filename in ["/index.html", "/"]:
Expand All @@ -122,7 +126,7 @@ def get_matching_redirect_with_path(
elif filename.endswith(".html"):
queryset = queryset.filter(page | exact | html_to_clean_url)
else:
queryset = queryset.filter(page | exact)
queryset = queryset.filter(page | exact | clean_url_without_trailing_slash_to_html)
else:
# If the filename is empty, we only need to match exact redirects.
# Since the other types of redirects are not valid without a filename.
Expand Down
15 changes: 15 additions & 0 deletions readthedocs/redirects/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from readthedocs.projects.models import Project
from readthedocs.redirects.constants import (
CLEAN_URL_TO_HTML_REDIRECT,
CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT,
EXACT_REDIRECT,
HTML_TO_CLEAN_URL_REDIRECT,
PAGE_REDIRECT,
Expand Down Expand Up @@ -210,6 +211,20 @@ def test_redirects_validations(self):
"Only one redirect of type `clean_url_to_html` is allowed per project.",
)

get(Redirect, redirect_type=CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT, project=self.project)
resp = self.client.post(
url,
data={
"redirect_type": CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT,
"http_status": 302,
},
)
self.assertEqual(resp.status_code, 200)
self.assertContains(
resp,
"Only one redirect of type `clean_url_without_trailing_slash_to_html` is allowed per project.",
)

get(Redirect, redirect_type=HTML_TO_CLEAN_URL_REDIRECT, project=self.project)
resp = self.client.post(
url,
Expand Down
7 changes: 6 additions & 1 deletion readthedocs/redirects/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from django.utils.translation import gettext_lazy as _

from readthedocs.redirects.constants import CLEAN_URL_TO_HTML_REDIRECT
from readthedocs.redirects.constants import CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT
from readthedocs.redirects.constants import EXACT_REDIRECT
from readthedocs.redirects.constants import HTML_TO_CLEAN_URL_REDIRECT
from readthedocs.redirects.constants import PAGE_REDIRECT
Expand Down Expand Up @@ -32,7 +33,11 @@ def validate_redirect(*, project, pk, redirect_type, from_url, to_url, error_cla
"The * wildcard must be at the end of from_url to use the :splat placeholder in to_url."
)

if redirect_type in [CLEAN_URL_TO_HTML_REDIRECT, HTML_TO_CLEAN_URL_REDIRECT]:
if redirect_type in [
CLEAN_URL_TO_HTML_REDIRECT,
CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT,
HTML_TO_CLEAN_URL_REDIRECT,
]:
redirect_exists = (
project.redirects.filter(redirect_type=redirect_type).exclude(pk=pk).exists()
)
Expand Down