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?
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
I think, in general, that if you have 14 distinct models that the view can query, keyed by table name, then that is significantly more complex than what you should be doing inside a view.
I would suggest writing 14 views, one for each model you intend to query. This doesn't need to be a lot of code. With generic views, you can define a queryset, a serializer class, and a permission class, pick whether you want a retrieve view, a list view, etc, and in most cases you are done. That's 4 lines of code per view, times 14 views, is ~60 lines of code, which is doable. I'd prefer to write this in the verbose but straightforward way versus the terse but complex way.
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
I agree with this point, and I'd actually go further. Not only do I think creditSummary should be part of the URL, I think it should also be part of the route. I think that if you have both /cases/<uuid>/records/creditSummary/ and /cases/<uuid>/records/paymentSummary/, and those are meaningfully different shapes of data / types, then they also ought to be different views.
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?
I think combining a read path and write path into a single view is fine and perfectly idiomatic DRF. I think the more questionable aspect is having the same view available at multiple URLs, and having only some of the methods work at each URL.
For example, you can GET /cases/<uuid:case_uuid>/records/, even though that doesn't provide a tab_name kwarg, or you can POST /cases/<uuid:case_uuid>/records/<str:tab_name>/, which provides a kwarg that isn't accepted. These are URLs accepted by Django's routing system but they're not semantically valid. That seems confusing. In this circumstance, where you want to map multiple URLs to a single view, but have different kwargs accepted for each method, I think what you actually want is a ViewSet.
Here's an example from the docs:
The example above would generate the following URL patterns:
URL pattern: ^users/$ Name: 'user-list'
URL pattern: ^users/{pk}/$ Name: 'user-detail'
If you squint, that's essentially what you wanted to do with providing an extra kwarg for certain methods of the APIView.
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
Some people who write DRF are big proponents of having service layer like this. I'm personally on the side of using serializers / generics / querysets to do most things like this, but you can absolutely do it this way.
It's hard to comment on specifics of TabReadService without knowing what it does.
You mention that the fields are combined with a containing class, and that the fields are camelCase, but these are all things you can do in a serializer. (Or you could use a regex to find / replace snake case to camel case if mapping each field individually in a serializer is too much code.)
I think your reasoning is pretty sound. I wouldn't use ?tab_name=creditSummary here either. Since creditSummary is really identifying which sub-resource you're asking for, I'd put it in the path.
Something like this seems perfectly reasonable:
path("cases/<uuid:case_uuid>/records/", RecordsView.as_view()),
path("cases/<uuid:case_uuid>/records/<str:tab_name>/", RecordsView.as_view()),
and then have GET call something like TabReadService.read(tab_name, case).
I don't think having the same view handle both is inherently a problem either. The fact that the existing class was originally written for POST doesn't mean it can't grow a GET if both operations belong to the same URL/resource.
The part I'd be a little more careful about is the service layer. Since the GET is doing a fairly specific aggregation of the latest A + latest B and reshaping the response, I'd keep that logic out of the view entirely. The view should basically just resolve the case/tab and hand it off to the read service.
I also wouldn't introduce a ViewSet/custom action just for the sake of being "more DRF". A ViewSet makes more sense if you're actually modelling a resource with the usual CRUD operations or several related actions. In your case, the existing URL structure already has a concept of records, and the tab is effectively a named sub-resource.
One thing I'd probably add is validation for tab_name rather than letting an arbitrary string reach the service:
service = TAB_READ_SERVICES.get(tab_name)
if not service:
raise NotFound("Unknown tab")
That also keeps the closed-enum behaviour explicit.
So I'd probably go with the second URL you proposed. It's consistent with the rest of the API, doesn't change the existing POST contract, and makes the GET endpoint pretty obvious from the URL. The fact that the response is built from multiple models doesn't really require a separate endpoint by itself.
Follow-up: here's what we actually shipped, for anyone hitting the same design question
Yes — this is what we shipped, and it holds up. We implemented it almost exactly as both answers proposed: tab_name as a path segment on a shared URL, one thin dispatcher view, all merge/reshape logic in a service class. One deliberate deviation from the second answer's "14 separate views" suggestion, explained below.
urls.py:
path("skip-trace-cases/<uuid:case_uuid>/records/", SkipTraceRecordsView.as_view()),
path("skip-trace-cases/<uuid:case_uuid>/records/<str:tab_name>/", SkipTraceRecordsView.as_view()),
views.py — the view stays a thin dispatcher, exactly as both answers recommended:
class SkipTraceRecordsView(APIView):
def get(self, request, case_uuid, tab_name=None):
if tab_name not in SkipTraceTabService.TAB_PROVIDER:
return Response({"error": "..."}, status=400)
try:
case = SkipTraceCase.objects.get(uuid=case_uuid, is_active=True)
except SkipTraceCase.DoesNotExist:
return Response(SkipTraceTabService.blank(tab_name))
return Response(SkipTraceTabService.get(tab_name, case))
def patch(self, request, case_uuid, tab_name=None):
return self._ingest(request, case_uuid, tab_name)
# put/delete/post follow the same shape
Where we diverged: the second answer argued for one view per entity (~14 total), since different tabs are "meaningfully different shapes of data." We kept a single view covering all 5 tabs across GET/PATCH/PUT/DELETE/POST, dispatched through a closed dict that doubles as the enum validation the first answer suggested:
TAB_PROVIDER: ClassVar[dict] = {
"creditSummary": "manual",
"experian": "experian",
"equifax": "equifax",
"idi": "idi",
"tlo": "tlo",
}
Reasoning: these 5 tabs aren't independent resources with their own lifecycle — they're facets of one skip-trace case, all keyed by the same (case, provider) pair. 4 HTTP methods × 5 tabs as separate views would have meant ~20 near-identical classes, versus one view plus 5 small per-tab handlers on the service. That service is where the "latest A + latest B, reshaped to camelCase" merge from the original question actually lives:
@staticmethod
def _get_credit_summary(case):
credit = SkipTraceCreditSummary.objects.filter(case=case, provider="manual").first()
prop = (
SkipTraceProperty.objects.filter(case=case, provider="manual")
.order_by("-createdAt", "-id")
.first()
)
return {
"creditScore": credit.credit_score if credit else "",
"mortgageAmount": prop.mortgage_balance if prop else "",
"propertyOwnership": OWNERSHIP_MAP.get(prop.ownership_type, "none") if prop else "none",
...
}
If a future tab needs a genuinely different lifecycle (its own versioning, permissions), splitting it into its own view at that point looks easier than un-merging 14 views now — so the single-dispatch-table approach felt like the safer default until something forces our hand.
Thanks to both answerers — path segment over query param, and merge logic in the service rather than the view, were the two calls that mattered most once this was actually built.