- ID
- 37bd9863-02a6-4850-b7bd-8e5dba4687c2
Non–trailing-slash API routes (BE-1827 follow-up audit)
Context
Follow-up to BE-1827: remove non-slash route versions of v2 endpoints once FE/Mobile have migrated to trailing-slash URLs.
Source repo (audit): =hungryroot= — URL wiring via =HRRestRouter= (=app/rest/utils/routers.py=) and =hrplatform/urls/api.py=.
How non-slash variants are created
Mechanism 1: =HRRestRouter(viewset_route_slash_required=False)=
Sets DRF =SimpleRouter.trailing_slash= to ="/?"=, so every =register_viewset= on *that same router instance* exposes both =…/= and =…= for generated list/detail/action routes.
Mechanism 2: =register_view= with route not ending in =/=
Registers *two* Django =path()= entries: slash-normalized form and a second without trailing slash; the second URL name gets suffix =-noslash= (see =HRRestRouter.register_view=).
Plain =path("something/", ...)= only defines the slash form — no automatic non-slash twin.
=/api/v2/= — Viewsets with optional trailing slash (=/?=)
Only =app/rest/urls.py= uses =HRRestRouter(viewset_route_slash_required=False)= for the main v2 app REST router.
Viewsets on *that* chained router (optional slash on DRF routes):
| Basename | Prefix |
|---------------------------------------+------------|
| =app-rest-address= | =addresses= |
| =app-rest-customer-detail-customer-id= | =customers= |
| =app-rest-gift= | =gifts= |
Nested routers in the same file use new =HRRestRouter()= instances (default =viewset_route_slash_required=True=), so e.g. checkout, nested customer orders, global orders, promo viewset, etc. do *not* get =/?= from this flag.
=*leads/urls.py*= uses stock DRF =SimpleRouter()= (not =HRRestRouter=); default behavior is trailing-slash URLs only — not the =/?= pattern.
=/api/v2/= — Explicit duplicate routes (=register_view= + =-noslash=)
Routes declared *without* trailing =/= get a second pattern; reverse name ends with =-noslash=.
=app/rest/urls.py=
- =customers/<str:customer_id>= (customer detail)
- =customers/<str:customer_id>/cart/bundles/<int:bundle_id>=
- =customers/<str:customer_id>/cart/bundles/<int:bundle_id>/products/<int:product_id>=
- =customers/<str:customer_id>/pairings/<int:pk>/swaps=
- =customers/<str:customer_id>/product_ratings/<int:pk>=
- =pairings/<int:pk>= (numeric pairing detail)
- Under nested =customers/<str:customer_id>/orders/=: =<int:pk>= (customer order detail)
=pricing_service/urls.py=
- =price_options= (under =pricing_service/customers/<str:customer_id>/=)
=/api/v2/= — Other included apps
Most v2 includes use =HRRestRouter()= without =viewset_route_slash_required=False=, and =register_view= paths usually end with =/= — no DRF =/?= and no =register_view= non-slash twin unless a route omits trailing =/=.
Inconsistency note
=plan_recommender/urls.py= uses =path("plan_recommendations", ...)= *without* trailing slash in the pattern. That is inconsistent with most v2 routes; not a “duplicate slash variant” but worth normalizing when touching that module.
=/api/v3/=
=app/rest/v3/urls.py= builds =HRRestRouter(viewset_route_slash_required=False)=, but viewsets are attached to *nested* =HRRestRouter()= instances. In practice the top-level =False= likely does not apply to any viewset today; v3 routes are mostly =register_view(..., ".../")= with explicit slashes.
Legacy and non–v2 JSON APIs
- *Legacy =/api/= v1* — =app/urls/apiv1.py= and many =re_path(..., r".../?$", ...)= patterns accept slash or not in one regex (different from v2 =HRRestRouter= behavior).
- *Internal / web / test* — e.g. =app/urls/internal_api.py=, =app/urls/web.py=, =app/urls/test.py=, =hrplatform/urls/api.py= (heartbeat, static, etc.) use =/?$= in =re_path= where applicable — not v2 JSON APIs, but still optional-slash URLs.
Suggested next steps (ticket acceptance)
1. Confirm client usage for: (a) =addresses= / =customers= / =gifts= viewsets under v2, and (b) explicit =-noslash= paths listed above.
2. Removal: set =viewset_route_slash_required= to default =True= on the main v2 router when safe; normalize every =register_view= route to end with =/=; update clients/tests that reverse =*-noslash= names.
3. Document stragglers with owning team if telemetry still shows non-slash hits.
Reference — key implementation
#+begin_src python
# app/rest/utils/routers.py — SLASH_ABSENT_ROUTE_NAME_SUFFIX = "-noslash"
# viewset_route_slash_required=False → self.trailing_slash = "/?"
# register_view: if route lacks trailing /, append second path(no_slash_route, ...)
#+end_src