Skip to content
Back to skills

Improving Drf Endpoints

ASecurity

Use when editing, reviewing, or auditing DRF viewsets and serializers in PostHog. Triggers on files in posthog/api/, products/*/backend/api/, products/*/backend/presentation/, or any file importing rest_framework. Covers field typing, schema annotations, enum collision fixes, and OpenAPI spec quality — everything that flows downstream into generated TypeScript types and MCP tools.

  • 40,048 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added September 1, 2026
developmenttypescriptpythongonodedjangoapifrontendbackend

Works with

  • cli
  • api
  • mcp

Security analysis

A100/100

Pro scans all 5 files and shows the line behind each finding

Scanned October 1, 2026

npx -y skills add PostHog/posthog --skill improving-drf-endpoints --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Improving Drf Endpoints?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Improving Drf Endpoints
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/posthog-improving-drf-endpoints-posthog/badge)](https://www.skillsdirectory.com/skills/posthog-improving-drf-endpoints-posthog)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: improving-drf-endpoints
description: Use when editing, reviewing, or auditing DRF viewsets and serializers in PostHog. Triggers on files in posthog/api/, products/*/backend/api/, products/*/backend/presentation/, or any file importing rest_framework. Covers field typing, schema annotations, enum collision fixes, and OpenAPI spec quality — everything that flows downstream into generated TypeScript types and MCP tools.
---

# Improving DRF Endpoints

Before you propose a contract test against the generated OpenAPI schema, check [things already tried](../../../docs/internal/ci-things-already-tried.md). Six PRs took that idea, and none merged.

## Overview

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

```text
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](references/serializer-fields.md#choicefield--explicit-choices))
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](references/serializer-fields.md) for patterns and examples.

### Viewset and action annotations

9. **Every custom `@action` has `@extend_schema` or `@validated_request`** — without it, drf-spectacular discovers zero parameters
10. **Plain `ViewSet` methods have schema annotations** — `ModelViewSet` with `serializer_class` is auto-discovered; plain `ViewSet` is not
11. **`@extend_schema` is on the actual method** (`get`, `post`, `create`, `list`), not on a helper or the class itself
12. **Error responses are typed** — use `OpenApiResponse(response=ErrorSerializer)`, not `OpenApiTypes.OBJECT`
13. **List endpoints declare pagination** — reset with `pagination_class=None` on custom actions that don't paginate
14. **Prefer `@validated_request`** over manual `serializer.is_valid()` + `@extend_schema` — it handles both in one decorator
15. **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
16. **`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](references/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:

```python
# 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

```dot
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](references/quick-reference-table.md) for a scannable "I see X, do Y" lookup.

See [common-anti-patterns.md](references/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

Files in this skill

  • SKILL.md10.9 KB
  • references/common-anti-patterns.md7.2 KB
  • references/quick-reference-table.md3.9 KB
  • references/serializer-fields.md5 KB
  • references/viewset-annotations.md8 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.