Repository: pypi/warehouse

Issue:
Add trailing slash redirects to JSON API 
The JSON API currently resolves URLs like `https://pypi.org/pypi/pip/json`, however adding a trailing slash (e.g. `https://pypi.org/pypi/pip/json/`) results in a 404, even though trailing slashes were valid in legacy PyPI.

PR https://github.com/pypa/warehouse/pull/3971 was an attempt to fix this, but because we have Pyramid configured to automatically add a trailing slash in the event of a 404, this resulted in a redirect loop.
Add trailing slash redirects to JSON API 
The JSON API currently resolves URLs like `https://pypi.org/pypi/pip/json`, however adding a trailing slash (e.g. `https://pypi.org/pypi/pip/json/`) results in a 404, even though trailing slashes were valid in legacy PyPI.

PR https://github.com/pypa/warehouse/pull/3971 was an attempt to fix this, but because we have Pyramid configured to automatically add a trailing slash in the event of a 404, this resulted in a redirect loop.


Discussion:
Is the fix for this to change the Pyramid configuration to special-case this? Or to support `https://pypi.org/pypi/pip/json/` without redirecting?
Is the fix for this to change the Pyramid configuration to special-case this? Or to support `https://pypi.org/pypi/pip/json/` without redirecting?

Resolving patch:
diff --git a/warehouse/legacy/api/json.py b/warehouse/legacy/api/json.py
--- a/warehouse/legacy/api/json.py
+++ b/warehouse/legacy/api/json.py
@@ -41,19 +41,21 @@
     "Access-Control-Expose-Headers": ", ".join(["X-PyPI-Last-Serial"]),
 }
 
+_CACHE_DECORATOR = [
+    cache_control(15 * 60),  # 15 minutes
+    origin_cache(
+        1 * 24 * 60 * 60,  # 1 day
+        stale_while_revalidate=5 * 60,  # 5 minutes
+        stale_if_error=1 * 24 * 60 * 60,  # 1 day
+    ),
+]
+
 
 @view_config(
     route_name="legacy.api.json.project",
     context=Project,
     renderer="json",
-    decorator=[
-        cache_control(15 * 60),  # 15 minutes
-        origin_cache(
-            1 * 24 * 60 * 60,  # 1 day
-            stale_while_revalidate=5 * 60,  # 5 minutes
-            stale_if_error=1 * 24 * 60 * 60,  # 1 day
-        ),
-    ],
+    decorator=_CACHE_DECORATOR,
 )
 def json_project(project, request):
     if project.name != request.matchdict.get("name", project.name):
@@ -75,18 +77,24 @@ def json_project(project, request):
     return json_release(release, request)
 
 
+@view_config(
+    route_name="legacy.api.json.project_slash",
+    context=Project,
+    decorator=_CACHE_DECORATOR,
+)
+def json_project_slash(project, request):
+    return HTTPMovedPermanently(
+        # Respond with redirect to url without trailing slash
+        request.route_path("legacy.api.json.project", name=project.name),
+        headers=_CORS_HEADERS,
+    )
+
+
 @view_config(
     route_name="legacy.api.json.release",
     context=Release,
     renderer="json",
-    decorator=[
-        cache_control(15 * 60),  # 15 minutes
-        origin_cache(
-            1 * 24 * 60 * 60,  # 1 day
-            stale_while_revalidate=5 * 60,  # 5 minutes
-            stale_if_error=1 * 24 * 60 * 60,  # 1 day
-        ),
-    ],
+    decorator=_CACHE_DECORATOR,
 )
 def json_release(release, request):
     project = release.project
@@ -180,3 +188,18 @@ def json_release(release, request):
         "releases": releases,
         "last_serial": project.last_serial,
     }
+
+
+@view_config(
+    route_name="legacy.api.json.release_slash",
+    context=Release,
+    decorator=_CACHE_DECORATOR,
+)
+def json_release_slash(project, request):
+    return HTTPMovedPermanently(
+        # Respond with redirect to url without trailing slash
+        request.route_path(
+            "legacy.api.json.release", name=project.name, version=project.version
+        ),
+        headers=_CORS_HEADERS,
+    )
diff --git a/warehouse/routes.py b/warehouse/routes.py
--- a/warehouse/routes.py
+++ b/warehouse/routes.py
@@ -221,6 +221,7 @@ def includeme(config):
         read_only=True,
         domain=warehouse,
     )
+
     config.add_route(
         "legacy.api.json.project",
         "/pypi/{name}/json",
@@ -229,6 +230,15 @@ def includeme(config):
         read_only=True,
         domain=warehouse,
     )
+    config.add_route(
+        "legacy.api.json.project_slash",
+        "/pypi/{name}/json/",
+        factory="warehouse.packaging.models:ProjectFactory",
+        traverse="/{name}",
+        read_only=True,
+        domain=warehouse,
+    )
+
     config.add_route(
         "legacy.api.json.release",
         "/pypi/{name}/{version}/json",
@@ -237,6 +247,14 @@ def includeme(config):
         read_only=True,
         domain=warehouse,
     )
+    config.add_route(
+        "legacy.api.json.release_slash",
+        "/pypi/{name}/{version}/json/",
+        factory="warehouse.packaging.models:ProjectFactory",
+        traverse="/{name}/{version}",
+        read_only=True,
+        domain=warehouse,
+    )
 
     # Legacy Action URLs
     # TODO: We should probably add Warehouse routes for these that just error


Test patch:
diff --git a/tests/unit/legacy/api/test_json.py b/tests/unit/legacy/api/test_json.py
--- a/tests/unit/legacy/api/test_json.py
+++ b/tests/unit/legacy/api/test_json.py
@@ -118,6 +118,23 @@ def test_only_prereleases(self, monkeypatch, db_request):
         assert json_release.calls == [pretend.call(release, db_request)]
 
 
+class TestJSONProjectSlash:
+    def test_normalizing_redirects(self, db_request):
+        project = ProjectFactory.create()
+
+        db_request.route_path = pretend.call_recorder(
+            lambda *a, **kw: "/project/the-redirect"
+        )
+
+        resp = json.json_project_slash(project, db_request)
+
+        assert isinstance(resp, HTTPMovedPermanently)
+        assert db_request.route_path.calls == [
+            pretend.call("legacy.api.json.project", name=project.name)
+        ]
+        assert resp.headers["Location"] == "/project/the-redirect"
+
+
 class TestJSONRelease:
     def test_normalizing_redirects(self, db_request):
         project = ProjectFactory.create()
@@ -428,3 +445,22 @@ def test_minimal_renders(self, pyramid_config, db_request):
             ],
             "last_serial": je.id,
         }
+
+
+class TestJSONReleaseSlash:
+    def test_normalizing_redirects(self, db_request):
+        release = ReleaseFactory.create()
+
+        db_request.route_path = pretend.call_recorder(
+            lambda *a, **kw: "/project/the-redirect"
+        )
+
+        resp = json.json_release_slash(release, db_request)
+
+        assert isinstance(resp, HTTPMovedPermanently)
+        assert db_request.route_path.calls == [
+            pretend.call(
+                "legacy.api.json.release", name=release.name, version=release.version
+            )
+        ]
+        assert resp.headers["Location"] == "/project/the-redirect"
diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py
--- a/tests/unit/test_routes.py
+++ b/tests/unit/test_routes.py
@@ -243,6 +243,14 @@ def add_policy(name, filename):
             read_only=True,
             domain=warehouse,
         ),
+        pretend.call(
+            "legacy.api.json.project_slash",
+            "/pypi/{name}/json/",
+            factory="warehouse.packaging.models:ProjectFactory",
+            traverse="/{name}",
+            read_only=True,
+            domain=warehouse,
+        ),
         pretend.call(
             "legacy.api.json.release",
             "/pypi/{name}/{version}/json",
@@ -251,6 +259,14 @@ def add_policy(name, filename):
             read_only=True,
             domain=warehouse,
         ),
+        pretend.call(
+            "legacy.api.json.release_slash",
+            "/pypi/{name}/{version}/json/",
+            factory="warehouse.packaging.models:ProjectFactory",
+            traverse="/{name}/{version}",
+            read_only=True,
+            domain=warehouse,
+        ),
         pretend.call("legacy.docs", docs_route_url),
     ]