How to expose a multi-model, case-scoped "detail" read in DRF without adding a new endpoint?
I have a Django REST Framework app with an established pattern: a generic detail view dispatches by an entity string to one of ~14 model/serializer/service triples, looked up by that record's own primary key:
class HandleEntityDetails(APIView):
MODELS = {"foo": Foo, "bar": Bar, ...} # ~14 entries
SERIALIZERS = {"foo": FooSerializer, ...}
def get(self, request, entity, object_uuid):
model_class = self.MODELS[entity.lower()]
obj = model_class.objects.get(uuid=object_uuid) # this row's OWN pk
return Response(self.SERIALIZERS[entity.lower()](obj).data)
A sibling view shares the same dispatch table but is scoped by a parent "case" uuid instead of a row's own uuid, returning a paginated array:
class HandleEntityList(APIView):
def get(self, request, entity, case_uuid):
case = Case.objects.get(uuid=case_uuid)
service = self.SERVICES[entity.lower()]
records = service.list(case) # queryset, many rows
return Response(self.paginate(records))
And a third view already does writes for a handful of "tab" concepts, dispatching on a tab_name string in the request body to a per-tab ingestion service:
class RecordsView(APIView):
def post(self, request, case_uuid):
case = Case.objects.get(uuid=case_uuid)
tab_name = request.data.get("tab_name") # e.g. "creditSummary"
result = TabIngestService.ingest(tab_name, request.data, case)
return Response(result, status=201)
I now need to read back what that last one writes for one tab: not one row in one table, but the most recent row from model A filtered by provider, merged with the most recent row from related model B (also filtered by provider), reshaped into camelCase keys that don't match either model's own field names 1:1.
What I don't want is a fourth generic-dispatch view purely to read this back — it feels like it should reuse something that already exists rather than growing yet another {entity: ...} table alongside the three above.
Why not just add ?tab_name=creditSummary as a query param on a new GET on RecordsView, symmetric with how POST reads it from the body? I keep landing on "no" for a few reasons and want to sanity-check them:
Every other identifier in this app's URLs (
entity,object_uuid,case_uuid) is a path segment, not a query string — a query param here would be the one inconsistent identifier in the whole API surface, for what's conceptually the same kind of thing (which fixed sub-resource of this case am I addressing).tab_nameisn't a filter over a collection (which is what query params are idiomatically for —?status=active,?ordering=-created) — it's a required, closed-enum identifier of a specific derived resource. A resource identifier that's optional/absent by default (as query params conventionally are) doesn't reflect that this parameter is mandatory and load-bearing.It reads better as a URL:
GET /cases/<uuid>/records/creditSummary/names the thing you're fetching;GET /cases/<uuid>/records/?tab_name=creditSummarymakes it look likerecords/is the resource andtab_nameis incidentally narrowing it, when actually there's no meaningfulrecords/response without it.
Concretely, I'm considering just adding GET to RecordsView with tab_name as a second URL kwarg (via a second path() entry pointing at the same view, since the existing POST URL has no trailing segment and I don't want to change it):
# urls.py
path("cases/<uuid:case_uuid>/records/", RecordsView.as_view()), # existing POST
path("cases/<uuid:case_uuid>/records/<str:tab_name>/", RecordsView.as_view()), # new GET
# views.py
class RecordsView(APIView):
def get(self, request, case_uuid, tab_name):
case = Case.objects.get(uuid=case_uuid)
return Response(TabReadService.read(tab_name, case)) # new, read-side twin of TabIngestService
def post(self, request, case_uuid):
... # unchanged
Is this — same view class, same dispatch concept, mirrored write/read service pair, path segment not query param — the idiomatic way to close this kind of read/write asymmetry in DRF? Or is there a cleaner pattern (a dedicated read-only viewset, a custom action, something else) that avoids bolting a GET onto a view whose name (RecordsView) and existing method (post) were written with only "create" in mind?