.agents/skills/improving-drf-endpoints/references/common-anti-patterns.md
# Before
class ActionSerializer(serializers.ModelSerializer):
name = serializers.CharField()
steps = serializers.ListField(child=ActionStepSerializer())
deleted = serializers.BooleanField()
# After
class ActionSerializer(serializers.ModelSerializer):
name = serializers.CharField(
help_text="Human-readable name for the action. Shown in the UI and used for filtering."
)
steps = serializers.ListField(
child=ActionStepSerializer(),
help_text="Ordered list of match conditions. An event matches if any step matches.",
)
deleted = serializers.BooleanField(
help_text="Whether the action has been soft-deleted. Deleted actions are excluded from queries.",
)
# Before — generates generic object, agents can't construct valid input
class HogFunctionSerializer(serializers.ModelSerializer):
inputs = serializers.JSONField(required=False)
# After — typed via Pydantic model
from pydantic import BaseModel
class HogFunctionInputs(BaseModel):
name: str
value: str | int | bool | None = None
secret: bool = False
@extend_schema_field(HogFunctionInputs) # type: ignore[arg-type]
class HogFunctionInputsField(serializers.JSONField):
pass
class HogFunctionSerializer(serializers.ModelSerializer):
inputs = HogFunctionInputsField(
required=False,
help_text="Input configuration for the Hog function. Each input has a name, value, and secret flag.",
)
# Before — generates z.array(z.unknown())
class BatchSerializer(serializers.Serializer):
ids = serializers.ListField(required=True)
# After — generates z.array(z.string())
class BatchSerializer(serializers.Serializer):
ids = serializers.ListField(
child=serializers.CharField(),
min_length=1,
max_length=100,
help_text="List of resource IDs to process.",
)
# Before — drf-spectacular discovers nothing
class LLMProxyViewSet(viewsets.ViewSet):
def completion(self, request, *args, **kwargs):
serializer = LLMProxyCompletionSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=400)
data = serializer.validated_data
# ...
# After — @validated_request handles validation + OpenAPI
from drf_spectacular.utils import OpenApiResponse
from posthog.api.mixins import validated_request
class LLMProxyViewSet(viewsets.ViewSet):
@validated_request(
request_serializer=LLMProxyCompletionSerializer,
responses={200: OpenApiResponse(response=LLMProxyResponseSerializer, description="LLM completion response")},
summary="LLM completion",
description="Proxy a completion request to the configured LLM provider.",
)
def completion(self, request, *args, **kwargs):
data = request.validated_data
# ...
# Before — decorator on class does nothing for APIView
@extend_schema(request=MySerializer)
class MyView(APIView):
def post(self, request):
...
# After — on the actual handler
class MyView(APIView):
@extend_schema(
request=MySerializer,
responses={201: MyResponseSerializer},
summary="Create a thing",
)
def post(self, request):
...
# Before — z.object({}) for errors
@extend_schema(
responses={
200: DashboardSerializer,
400: OpenApiTypes.OBJECT,
500: OpenApiTypes.OBJECT,
},
)
# After — descriptive responses
@extend_schema(
responses={
200: DashboardSerializer,
400: OpenApiResponse(description="Validation error — field-level error details"),
404: OpenApiResponse(description="Dashboard not found"),
},
)
# Before — return type is unknown in OpenAPI
class TeamSerializer(serializers.ModelSerializer):
member_count = serializers.SerializerMethodField()
def get_member_count(self, obj):
return obj.members.count()
# After — return type declared
from drf_spectacular.utils import extend_schema_field
class TeamSerializer(serializers.ModelSerializer):
member_count = serializers.SerializerMethodField(
help_text="Number of members in this team"
)
@extend_schema_field(serializers.IntegerField())
def get_member_count(self, obj):
return obj.members.count()
# Before — MCP tool gets zero parameters
@action(detail=False, methods=["post"], url_path="evaluate")
def evaluate(self, request, **kwargs):
serializer = EvaluateRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
...
# After — schema declared
@extend_schema(
request=EvaluateRequestSerializer,
responses={200: EvaluateResponseSerializer},
summary="Run evaluation",
description="Execute an evaluation run against the specified dataset.",
)
@action(detail=False, methods=["post"], url_path="evaluate")
def evaluate(self, request, **kwargs):
serializer = EvaluateRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
...
# Before — any string accepted, agents guess at values
status = serializers.CharField(help_text="The status to filter by")
# After — valid values enumerated
status = serializers.ChoiceField(
choices=["active", "archived", "deleted"],
help_text="Filter by resource status.",
)
# Before — computed fields cause validation errors on write
class ExperimentSerializer(serializers.ModelSerializer):
results = serializers.JSONField(read_only=True) # Computed
created_by = UserBasicSerializer(read_only=True) # Computed
class Meta:
model = Experiment
fields = "__all__"
# After — separate serializers
class ExperimentWriteSerializer(serializers.ModelSerializer):
class Meta:
model = Experiment
fields = ["name", "description", "feature_flag_key", "filters"]
class ExperimentReadSerializer(serializers.ModelSerializer):
results = serializers.JSONField(read_only=True)
created_by = UserBasicSerializer(read_only=True)
class Meta:
model = Experiment
fields = "__all__"
# In the viewset:
def get_serializer_class(self):
if self.action in ("create", "update", "partial_update"):
return ExperimentWriteSerializer
return ExperimentReadSerializer