Customise serializer context¶
DRF's get_serializer_context() lives on every GenericAPIView and
returns {"request", "view", "format"} by default. When the standard
viewset shape uses one serializer per action it's enough — but service-
backed views run two serializers per request (input and output), and
sometimes those two need different context. The library plumbs that
without forcing you to override get_serializer_context() and branch on
direction.
Four layers, later wins on overlap:
- DRF default —
view.get_serializer_context(). Always applied. - Directional fallback —
get_input_serializer_context()/get_output_serializer_context()on the view. Skipped when the method is absent, so plain DRF viewsets work unchanged. - Per-action override —
get_<action>_input_serializer_context()/get_<action>_output_serializer_context(). Viewset-only; skipped on standalone single-purpose views. - Per-spec callable —
ServiceSpec.input_serializer_context/ServiceSpec.output_selector_spec.output_serializer_context/SelectorSpec.output_serializer_context. Co-located with the spec that backs the action. Has the final say.
The resolver is wired into service-backed views, viewset mixins,
@service_action, and @selector_action. The standalone
SelectorListView / SelectorRetrieveView override
get_serializer_context() directly; the SelectorListMixin /
SelectorRetrieveMixin viewset mixins inherit the same override from
_ActionSpecsMixin. Either way, a SelectorSpec.output_serializer_context
flows into DRF's ListModelMixin / RetrieveModelMixin dispatch.
Different context for input vs. output¶
A common case: the input serializer needs a "current tenant" for cross-field validation, but the output serializer doesn't.
class AuthorViewSet(ServiceViewSet):
queryset = Author.objects.all()
action_specs = {
"create": ServiceSpec(
service=create_author,
input_serializer=CreateAuthorInput,
output_selector_spec=SelectorSpec(
kind=SelectorKind.RETRIEVE,
output_serializer=AuthorOutputSerializer,
),
),
}
def get_input_serializer_context(self):
return {"tenant": self.request.tenant}
Inside the input serializer:
class CreateAuthorInput(serializers.Serializer):
name = serializers.CharField()
def validate_name(self, value):
tenant = self.context["tenant"]
if Author.objects.filter(tenant=tenant, name=value).exists():
raise serializers.ValidationError("name already taken in this tenant")
return value
The output serializer keeps the default DRF context.
Per-action override¶
When only one action needs the extra key, name the hook after the
action — no branching on self.action:
_author_out = SelectorSpec(
kind=SelectorKind.RETRIEVE,
output_serializer=AuthorOutputSerializer,
)
class AuthorViewSet(ServiceViewSet):
action_specs = {
"create": ServiceSpec(
service=create_author,
input_serializer=CreateAuthorInput,
output_selector_spec=_author_out,
),
"update": ServiceSpec(
service=update_author,
input_serializer=UpdateAuthorInput,
output_selector_spec=_author_out,
),
}
def get_create_input_serializer_context(self):
return {"signup_token": self.request.headers.get("X-Signup-Token")}
update keeps the directional / DRF default; create adds the token on
top.
Per-spec context (the fourth layer)¶
When context belongs with the spec rather than the view, put it on the
spec. This pairs the input/output context with the service/selector it
feeds — no second method on the view, no if self.action == ....
class AuthorViewSet(ServiceViewSet):
queryset = Author.objects.all()
action_specs = {
"create": ServiceSpec(
service=create_author,
input_serializer=CreateAuthorInput,
input_serializer_context=lambda view, request: {
"tenant": request.tenant,
},
output_selector_spec=SelectorSpec(
kind=SelectorKind.RETRIEVE,
output_serializer=AuthorOutputSerializer,
output_serializer_context=lambda view, request: {
"include_links": "links" in request.query_params,
},
),
),
"list": SelectorSpec(
kind=SelectorKind.LIST,
selector=list_authors,
output_serializer=AuthorOutputSerializer,
output_serializer_context=lambda view, request: {
"include_links": True,
},
),
}
SelectorSpec carries only output_serializer_context — selectors don't
validate input, so there's no symmetrical input hook. On ServiceSpec,
the output hook lives on the nested output_selector_spec; the input
hook stays at the top level. The callable receives the calling view
(typed as ServiceView) and the DRF Request.
Output context that depends on the resolved data¶
Sometimes the output serializer needs data derived from the very objects it's about to render — and you want that derived data fetched in a single batched query rather than one query per row. The output context provider can receive the resolved data and run that one query.
An output context provider (the directional
get_output_serializer_context hook, the per-action
get_<action>_output_serializer_context hook, or the spec-level
output_serializer_context) may declare an extra keyword parameter
naming the data about to be serialized:
| Action | Keyword | Value |
|---|---|---|
| mutation output | result |
the final (post-output_selector_spec) instance |
| retrieve | instance |
the resolved object |
| list | page |
the paginated object list (the full queryset when pagination is off) |
The extra is passed only when the provider declares it (or accepts
**kwargs), so existing (view, request) providers keep working
unchanged — view and request are always positional. The provider
always runs after the data is resolved, so the value is real, not a
placeholder.
from django.db.models import Count
class PostViewSet(SelectorViewSet):
queryset = Post.objects.all()
action_specs = {
"list": SelectorSpec(
kind=SelectorKind.LIST,
selector=list_posts,
output_serializer=PostOutputSerializer,
# One query for the whole page, keyed on the page's ids.
output_serializer_context=lambda view, request, *, page: {
"comment_counts": dict(
Comment.objects.filter(post__in=page)
.values_list("post")
.annotate(n=Count("id"))
),
},
),
}
The serializer reads self.context["comment_counts"][obj.id] per row,
with no extra query. The same shape works for a mutation
(*, result) or a retrieve (*, instance), and on the view-method
hooks too — e.g. def get_list_output_serializer_context(self, *, page): ....
For an unpaginated list the page value is the full queryset; reading
ids off it ([p.id for p in page]) reuses the same evaluated queryset
DRF serializes, so it still costs one batched query, not two.
The input context provider has no such extra — there is no resolved output before the service runs.
On standalone views¶
Standalone mutation views (ServiceCreateView, ServiceUpdateView,
ServiceDeleteView) and @service_action honour the directional hooks
(get_input_serializer_context / get_output_serializer_context) plus
the spec layer (ServiceSpec.input_serializer_context and
ServiceSpec.output_selector_spec.output_serializer_context). The
per-action hooks only apply
where there is an action — i.e. inside viewset mixins and
@service_action / @selector_action.
Standalone SelectorListView / SelectorRetrieveView honor
SelectorSpec.output_serializer_context through their
get_serializer_context() override. They do not honor the directional
get_output_serializer_context hook there — that hook is reserved for the
mutation flow's input/output split. If you want a shared "always add X to
the context" on a selector view, override get_serializer_context()
directly.