improving-drf-endpoints

작성자: posthog

PostHog에서 DRF 뷰셋과 시리얼라이저를 편집, 검토 또는 감사할 때 사용합니다. posthog/api/, products/*/backend/api/,…의 파일에서 트리거됩니다.

npx skills add https://github.com/posthog/posthog-foss --skill improving-drf-endpoints

Improving DRF Endpoints

Before you propose a contract test against the generated OpenAPI schema, check things already tried. Six PRs took that idea, and none merged.

Overview

Serializer fields are the source of truth for PostHog's entire type pipeline:

Django serializer → drf-spectacular → OpenAPI JSON → Orval → Zod schemas → MCP tools

Every help_text, every field type, every @extend_schema annotation flows downstream. A missing help_text means an agent guessing at parameters. A bare ListField() means z.unknown() in the generated Zod schema. Getting the serializer right means every consumer — frontend types, MCP tools, API docs — gets correct types and descriptions automatically.

When to use

  • Editing or reviewing any file that defines a Serializer or ViewSet
  • Fixing OpenAPI spec warnings or generated type issues
  • Preparing an endpoint for MCP tool exposure
  • Code review of API changes

Audit checklist

Triage: check the generated output first

Before diving into Python, look at the committed generated types to see what's broken. Find the generated files for the endpoint's product:

  • Core API: frontend/src/generated/core/
  • Product APIs: products/<product>/frontend/generated/

Each has two files:

  • api.schemas.ts — TypeScript interfaces derived from serializers. Search for the serializer name and look for unknown types (bare ListField/JSONField), missing JSDoc descriptions (missing help_text), or overly generic Record<string, unknown> shapes.
  • api.ts — API client functions. Check if the endpoint's operation exists at all — if missing, the viewset method likely lacks @extend_schema.

This tells you exactly which fields and endpoints to prioritize.

Serializer fields

Work through this list for every serializer and viewset you touch.

  1. Every field has help_text — describes purpose, format, constraints, valid values
  2. No bare ListField() or DictField() — always specify child= with a typed serializer or field
  3. No bare JSONField() — create a custom field class with @extend_schema_field(TypedSchema)
  4. SerializerMethodField has @extend_schema_field on its get_* method
  5. ChoiceField has explicit choices= with all valid values listed
  6. Define choices as a class — models.TextChoices in a product's internal modules, LabeledStrEnum or LabeledIntEnum from posthog/enums.py in facade contract files (backend/facade/contracts.py, backend/facade/enums.py), which must not import Django. The OpenAPI component is named after the class either way (EarlyAccessFeature.Stage -> EarlyAccessFeatureStageEnum, via ChoicesEnumNameOverrides in posthog/openapi/enum_names.py), so a class-backed enum never collides on field names like format, type, status, kind. Pass X.choices to choices=, and to name a labeled enum on a SerializerMethodField use @extend_schema_field(ChoiceField(choices=X.choices)), not a return type hint. Inline choices=[...] lists have no class to read, collide with existing choices, and fail CI under --fail-on-warn; an explicit ENUM_NAME_OVERRIDES entry in posthog/settings/web.py is the fallback for choice sets no class can carry (see serializer-fields.md)
  7. Read vs write serializers are separate when input shape differs from output
  8. Every success response is backed by a serializer — returning raw dicts or untyped lists means no generated types downstream

See serializer-fields.md for patterns and examples.

Viewset and action annotations

  1. Every custom @action has @extend_schema or @validated_request — without it, drf-spectacular discovers zero parameters
  2. Plain ViewSet methods have schema annotations — ModelViewSet with serializer_class is auto-discovered; plain ViewSet is not
  3. @extend_schema is on the actual method (get, post, create, list), not on a helper or the class itself
  4. Error responses are typed — use OpenApiResponse(response=ErrorSerializer), not OpenApiTypes.OBJECT
  5. List endpoints declare pagination — reset with pagination_class=None on custom actions that don't paginate
  6. Prefer @validated_request over manual serializer.is_valid() + @extend_schema — it handles both in one decorator
  7. ViewSets outside products/ need @extend_schema(extensions={"x-product": "<product>"}) — ViewSets in products/<name>/backend/ are auto-attributed via module path; ViewSets in posthog/api/ or ee/ aren't and must declare attribution explicitly via the x-product extension. Accepts a plain string ("product_analytics") or ProductKey.X enum (kebab values are normalized). Don't use tags=["<product>"] to influence codegen routing — tags is for Swagger UI display only. Without x-product, the MCP scaffold and frontend type generator can't route the endpoint to the right product
  8. partial_update request= override must be a superset of runtime write fields — extend_schema(request=CustomSerializer) replaces drf-spectacular's inference from serializer_class; omitted fields disappear from OpenAPI, frontend types, and MCP tool schemas even when the runtime serializer still accepts them. After changing the override, run hogli build:openapi and verify generated MCP tool schemas still expose every OpenAPI body field

Streaming endpoints: For SSE or streaming responses, use @extend_schema(request=InputSerializer, responses={(200, "text/event-stream"): OpenApiTypes.STR}) to document the request schema even though the response can't be fully typed.

See viewset-annotations.md for patterns and examples.

URL routing — where to register new team-nested endpoints

PostHog briefly split projects and environments as separate concepts then rolled the split back. /api/projects/:team_id/... is the canonical path for any team-nested endpoint. /api/environments/:team_id/... is a backward-compat alias preserved only for clients that integrated against it during the split.

For a new team-nested endpoint, register it under routers.projects. Routes live in each product's own products/<name>/backend/routes.py, in a register_routes(routers) function:

# products/<name>/backend/routes.py
from posthog.api.routing import RouterRegistry


def register_routes(routers: RouterRegistry) -> None:
    routers.projects.register(r"my_thing", MyThingViewSet, "project_my_thing", ["team_id"])

Product routes are auto-discovered — posthog/api/__init__.py iterates INSTALLED_APPS and calls register_routes(routers) on every products.* app that has a routes.py. Adding a product needs no edit to core: create products/<name>/backend/routes.py and make sure the product is in PRODUCTS_APPS (posthog/settings/web.py). Only core, non-product viewsets still register directly in __init__.py.

Why core discovers and calls the product (not the product calling core). Core registers the four parents (root + projects/environments/organizations) first, then runs the discovery loop. Products only nest onto those parents and never onto each other, so discovery order is irrelevant. The registration is kept eager (it runs when posthog.api is first imported, i.e. on the first request) and deliberately not moved into AppConfig.ready(): ready() runs inside django.setup() in every process, and registering a route imports its viewset, so that would pull the whole API into setup() everywhere — regressing the laziness that keeps the API out of Celery workers and management commands. See the RouterRegistry docstring and the discovery loop in posthog/api/__init__.py for the full reasoning.

Register team-nested endpoints under routers.projects with a project_<name> basename. There is no environments_router and no dual-route helper: the legacy /api/environments/* surface has been retired as a set of registered routes.

Existing clients that still call /api/environments/... are served transparently by EnvironmentsRewriteMiddleware, which rewrites the path onto the equivalent /api/projects/* viewset in-process (no 307). You never register an env route for this — just register under routers.projects and the middleware handles the alias.

Facade products (DataclassSerializer)

For products using the facade pattern (e.g., visual_review) with DataclassSerializer wrapping frozen dataclasses from contracts.py:

  • Field types are auto-derived from the dataclass — fewer typing issues by design
  • Focus on help_text (dataclass fields don't carry it; add it on the serializer field overrides)
  • @validated_request is already the standard pattern — verify response serializers are declared
  • @extend_schema tags and descriptions still need to be set on viewset methods

Decision flowchart

digraph audit {
    rankdir=TB
    node [shape=diamond fontsize=10]
    edge [fontsize=9]

    start [label="Serializer or\nViewSet file?" shape=box]
    is_model [label="ModelViewSet with\nserializer_class?"]
    is_plain [label="Plain ViewSet or\ncustom @action?"]
    is_facade [label="DataclassSerializer\n(facade product)?"]

    check_fields [label="Check fields:\nhelp_text, ListField,\nJSONField, ChoiceField" shape=box]
    add_schema [label="Add @validated_request\nor @extend_schema to\nevery method" shape=box]
    check_help [label="Focus on help_text\nand response declarations" shape=box]
    check_responses [label="Check response types,\npagination, error schemas" shape=box]

    start -> is_model
    is_model -> check_fields [label="yes"]
    is_model -> is_plain [label="no"]
    is_plain -> add_schema [label="yes"]
    is_plain -> is_facade [label="no"]
    is_facade -> check_help [label="yes"]
    check_fields -> check_responses
    add_schema -> check_fields
    check_help -> check_responses
}

Quick reference

See quick-reference-table.md for a scannable "I see X, do Y" lookup.

See common-anti-patterns.md for before/after code pairs.

Canonical examples in the codebase

  • JSONField + @extend_schema_field: products/alerts/backend/presentation/views/alert.py
  • @validated_request: products/tasks/backend/presentation/views/api.py
  • help_text + typed responses: products/ai_observability/backend/api/summarization.py
  • Facade product: products/visual_review/backend/presentation/views.py

Related

  • Downstream: After fixing serializers, use the implementing-mcp-tools skill to scaffold MCP tools
  • Pipeline docs: docs/published/handbook/engineering/type-system.md
  • Mixins: posthog/api/mixins.py (@validated_request source)
  • drf-spectacular config: posthog/settings/web.py (SPECTACULAR_SETTINGS)
  • Enum collision diagnostic: python manage.py find_enum_collisions — finds unresolved collisions and suggests overrides

posthog의 다른 스킬

error-tracking-hono
posthog
PostHog 오류 추적 for Hono
tuning-incremental-sync-config
posthog
동기화의 구성은 ExternalDataSchema에 저장되며, external-data-schemas-partial-update를 통해 언제든지 변경할 수 있습니다. 대부분의 변경은 비파괴적이며(다음 동기화에 적용됨), 일부 변경(sync_type 전환, 기본 키 변경)은 동기화된 데이터 손상을 방지하기 위해 신중한 처리가 필요합니다.
playwright-test
posthog
플레이라이트 테스트를 작성하고, 실행이 잘 되며, 불안정하지 않도록 하세요.
error-tracking-ruby
posthog
PostHog Ruby 오류 추적
authoring-log-alerts
posthog
PostHog 프로젝트의 서비스에 유용하고 노이즈가 적은 로그 알림을 작성합니다. 사용자가 로그에 대한 알림 설정을 요청하거나 추가해야 할 알림을 제안할 때 사용하세요.
making-scenes-tab-aware
posthog
Guides converting PostHog frontend scenes to be tab aware for internal scene tabs. Use when adding or refactoring a `SceneExport` scene, fixing state leaking…
posthog-survey-creator
posthog
PostHog에서 안내 대화를 통해 설문조사를 생성하고 구성합니다. 사용자가 설문조사를 만들거나, 사용자 피드백을 수집하거나, 실행하려 할 때 이 스킬을 사용하세요.
authoring-scouts
posthog
PostHog Signals 스카우트를 작성, 편집 및 조정하는 방법 — 프로젝트를 스캔하고 Signals 인박스에 보고서를 작성하는 예약된 에이전트입니다. 사용자가…