Skip to content

Commit 71b7265

Browse files
committed
[NDH-304] Cleanly handle trailing slashes in Django paths
1 parent f34fe6a commit 71b7265

File tree

9 files changed

+169
-30
lines changed

9 files changed

+169
-30
lines changed

backend/app/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
CORS_ALLOWED_METHODS = ['GET']
8484

8585
ROOT_URLCONF = 'app.urls'
86+
APPEND_SLASH = True # this is default, but we're making sure it's explicit
8687

8788
TEMPLATES = [
8889
{

backend/app/tests/__init__.py

Whitespace-only changes.

backend/app/tests/test_routing.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
from django.urls import resolve, reverse
2+
from django.test import TestCase
3+
4+
5+
class ProviderDirectorySpaRouting(TestCase):
6+
"""
7+
Ensure that provider_directory routing is agnostic to paths.
8+
"""
9+
10+
def test_homepage_url_resolves_to_correct_view(self):
11+
resolver = resolve("/")
12+
self.assertEqual(resolver.view_name, "provider_directory:index")
13+
self.assertEqual(resolver.func.__name__, "index")
14+
15+
def test_detail_url_resolves_with_path(self):
16+
resolver = resolve("/search/123/")
17+
self.assertEqual(resolver.view_name, "provider_directory:index_with_path")
18+
self.assertEqual(resolver.kwargs["path"], "search/123/")
19+
20+
def test_reverse_index_returns_path(self):
21+
path = reverse("provider_directory:index")
22+
self.assertEqual(path, "/")
23+
24+
def test_reverse_index_with_path_returns_path(self):
25+
path = reverse("provider_directory:index_with_path", kwargs={"path": "search/123"})
26+
self.assertEqual(path, "/search/123")
27+
28+
29+
class FhirApiRouting(TestCase):
30+
"""
31+
Ensure we maintain a consistent routing configuration, with or without trailing slashes.
32+
"""
33+
34+
def assert_valid_routing(self, path_views):
35+
for path, expected_view_name in path_views:
36+
with self.subTest(f"route {path}"):
37+
resolver = resolve(path)
38+
self.assertEqual(resolver.view_name, expected_view_name)
39+
40+
def test_fhir_accepts_optional_trailing_slash(self):
41+
path_views = [
42+
("/fhir", "api-root"),
43+
("/fhir/", "api-root"),
44+
]
45+
self.assert_valid_routing(path_views)
46+
47+
def test_fhir_docs_accepts_optional_trailing_slash(self):
48+
path_views = [
49+
("/fhir/docs", "schema-swagger-ui"),
50+
("/fhir/docs/", "schema-swagger-ui"),
51+
# fallthrough
52+
("/fhirdocs", "provider_directory:index_with_path"),
53+
("/fhirdocs/", "provider_directory:index_with_path"),
54+
]
55+
self.assert_valid_routing(path_views)
56+
57+
def test_fhir_rest_routes_accept_optional_trailing_slash(self):
58+
path_views = [
59+
# valid paths
60+
("/fhir/Endpoint", "fhir-endpoint-list"),
61+
("/fhir/Endpoint/", "fhir-endpoint-list"),
62+
("/fhir/Endpoint/12345", "fhir-endpoint-detail"),
63+
("/fhir/Endpoint/12345/", "fhir-endpoint-detail"),
64+
# fallthrough
65+
("/fhirEndpoint", "provider_directory:index_with_path"),
66+
("/fhirEndpoint/", "provider_directory:index_with_path"),
67+
("/fhirEndpoint/12345", "provider_directory:index_with_path"),
68+
("/fhirEndpoint/12345/", "provider_directory:index_with_path"),
69+
("/fhir/Endpoint12345", "provider_directory:index_with_path"),
70+
("/fhir/Endpoint12345/", "provider_directory:index_with_path"),
71+
("/fhirEndpoint12345", "provider_directory:index_with_path"),
72+
("/fhirEndpoint12345/", "provider_directory:index_with_path"),
73+
]
74+
self.assert_valid_routing(path_views)
75+
76+
def test_fhir_rest_routes_reverse_without_slash(self):
77+
endpoint_list_path = reverse("fhir-endpoint-list")
78+
self.assertEqual(endpoint_list_path, "/fhir/Endpoint")
79+
80+
endpoint_detail_path = reverse("fhir-endpoint-detail", kwargs={"pk": 12345})
81+
self.assertEqual(endpoint_detail_path, "/fhir/Endpoint/12345")

backend/app/urls.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,38 @@
1818
from django.urls import include, path
1919
from debug_toolbar.toolbar import debug_toolbar_urls
2020

21+
from npdfhir.router import router as npdfhir_router
22+
2123
urlpatterns = [
24+
##
25+
# NOTE: (@abachman-dsac) on trailing slashes and routes
26+
#
27+
# The common django pattern for permitting optional trailing slashes:
28+
#
29+
# re_path('fhir/?', ...)
30+
#
31+
# Allows "/fhirdocs/"" to be a vaild URL, which we do not want.
32+
#
33+
# Additionally, the use of an empty path string in npdfhir.urls' urlpatterns
34+
# `path('', ...)` configuration to mount the rest_framework default router
35+
# means that we cannot use a single path matcher with 'fhir/' to mount the
36+
# whole npdfhir app and still resolve https://$hostnam/fhir to the
37+
# rest_framework.DefaultRouter built-in documentation page.
38+
#
39+
# By using two path matchers here and pointing the no-trailing-slash path
40+
# directly at the rest_framework router api-root view, we can get true
41+
# optional trailing slashes.
42+
#
43+
# The risk for flakiness is due to the fact that `/fhir` is handled by the
44+
# path configuration in this file and `/fhir/` is handled by the path
45+
# configuration in `npdfhir.urls` at the `path('', ...)` location.
46+
#
47+
# See app/tests/test_routing.py for validation tests to ensure that changes
48+
# inside npdfhir.urls don't break our routing configuration.
2249
path('fhir/', include("npdfhir.urls")),
50+
path('fhir', npdfhir_router.get_api_root_view, name='api-root'),
51+
##
52+
2353
path('admin/', admin.site.urls),
2454
path('', include('provider_directory.urls')),
2555
] + debug_toolbar_urls()

backend/npdfhir/router.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from rest_framework.routers import DefaultRouter
2+
3+
from . import views
4+
5+
6+
# NOTE: (@abachman-dsac) even though we're using Django's APPEND_SLASH option,
7+
# we want to ensure we are liberal in what we accept, and strict in what we
8+
# generate for API paths.
9+
#
10+
# ACCEPT:
11+
# - /Endpoint
12+
# - /Endpoint/
13+
# - /Endpoint/12345
14+
# - /Endpoint/12345/
15+
#
16+
# PRODUCE:
17+
# - /Endpoint
18+
# - /Endpoint/12345
19+
#
20+
class OptionalSlashRouter(DefaultRouter):
21+
def __init__(self, *args, **kwargs):
22+
super().__init__(*args, **kwargs)
23+
self.trailing_slash = "/?"
24+
25+
26+
router = OptionalSlashRouter()
27+
router.register(r"Practitioner", views.FHIRPractitionerViewSet, basename="fhir-practitioner")
28+
router.register(r"Organization", views.FHIROrganizationViewSet, basename="fhir-organization")
29+
router.register(r"Endpoint", views.FHIREndpointViewSet, basename="fhir-endpoint")

backend/npdfhir/urls.py

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1-
from django.urls import path, include
2-
from rest_framework.routers import DefaultRouter
1+
from django.urls import path, include, re_path
32
from rest_framework.schemas import get_schema_view
4-
from rest_framework.renderers import JSONOpenAPIRenderer
5-
from . import views
63
from debug_toolbar.toolbar import debug_toolbar_urls
74
from drf_yasg.views import get_schema_view
85
from drf_yasg import openapi
96

7+
from . import views
8+
from .router import router
9+
1010
schema_view = get_schema_view(
1111
openapi.Info(
1212
title="NPD FHIR API",
13-
default_version='v1',
13+
default_version="v1",
1414
description="The National Provider Directory FHIR API exposes public information on all Providers and Organizations that have registered through the National Provider and Payer Enumeration System NPPES. This is a limited beta release; coverage and data quality will increase iteratively.",
1515
terms_of_service="TBD",
1616
contact=openapi.Contact(email="opensource@cms.hhs.gov"),
@@ -19,22 +19,11 @@
1919
public=True,
2020
)
2121

22-
router = DefaultRouter()
23-
router.register(r'Practitioner', views.FHIRPractitionerViewSet,
24-
basename='fhir-practitioner')
25-
router.register(r'Organization', views.FHIROrganizationViewSet,
26-
basename='fhir-organization')
27-
router.register(r'Endpoint', views.FHIREndpointViewSet,
28-
basename='fhir-endpoint')
29-
3022
urlpatterns = [
31-
path('docs.<format>/',
32-
schema_view.without_ui(cache_timeout=0), name='schema-json'),
33-
path('docs/', schema_view.with_ui('swagger',
34-
cache_timeout=0), name='schema-swagger-ui'),
23+
path("docs.<format>/", schema_view.without_ui(cache_timeout=0), name="schema-json"),
24+
re_path("docs/?", schema_view.with_ui("swagger", cache_timeout=0), name="schema-swagger-ui"),
3525
path("healthCheck", views.health, name="healthCheck"),
3626
# path('metadata', views.fhir_metadata, name='fhir-metadata'),
37-
38-
# Router URLs
39-
path('', include(router.urls), name='index')
27+
# everything else is passed to the rest_framework router to manage
28+
path("", include(router.urls), name="index"),
4029
] + debug_toolbar_urls()

backend/provider_directory/tests/test_views.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
from django.urls import reverse
77

88
TEST_DIR = Path(os.path.dirname(os.path.abspath(__file__)))
9-
STATIC_INDEX = TEST_DIR / '..' / 'static' / 'index.html'
9+
STATIC_INDEX = TEST_DIR / ".." / "static" / "index.html"
10+
1011

1112
class WithoutStaticIndex(TestCase):
1213
"""
@@ -22,8 +23,9 @@ def test_index_redirects_to_vite_in_development(self):
2223
"""
2324
When static/index.html doesn't exist, route redirects
2425
"""
25-
response = self.client.get(reverse('index'))
26-
self.assertRedirects(response, 'http://localhost:3000/', fetch_redirect_response=False)
26+
response = self.client.get(reverse("provider_directory:index"))
27+
self.assertRedirects(response, "http://localhost:3000/", fetch_redirect_response=False)
28+
2729

2830
class WithStaticIndex(TestCase):
2931
"""
@@ -33,12 +35,12 @@ class WithStaticIndex(TestCase):
3335
@classmethod
3436
def setUpTestData(cls):
3537
if not os.path.exists(STATIC_INDEX):
36-
with open(STATIC_INDEX, 'a') as index:
37-
index.write(f'\n<!-- test content {datetime.now()} -->')
38+
with open(STATIC_INDEX, "a") as index:
39+
index.write(f"\n<!-- test content {datetime.now()} -->")
3840

3941
def test_index_serves_static_file(self):
4042
"""
4143
When static/index.html exists, route serves it
4244
"""
43-
response = self.client.get(reverse('index'))
44-
self.assertContains(response, 'test content', status_code=HTTPStatus.OK)
45+
response = self.client.get(reverse("provider_directory:index"))
46+
self.assertContains(response, "test content", status_code=HTTPStatus.OK)

backend/provider_directory/urls.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
from . import views
33
from debug_toolbar.toolbar import debug_toolbar_urls
44

5+
app_name = "provider_directory"
56
urlpatterns = [
6-
path(r'', views.index, name='index'),
7-
path(r'<path:path>', views.index),
8-
]
7+
path(r"", views.index, name="index"),
8+
path(r"<path:path>", views.index, name="index_with_path"),
9+
]

backend/ruff.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
line-length = 100
2+
3+
[format]
4+
quote-style = "double"
5+
indent-style = "space"
6+
docstring-code-format = true

0 commit comments

Comments
 (0)