Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci-frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,27 @@ jobs:
- name: Check if mobile replay "schema.json" is up to date
run: pnpm --filter=@posthog/ee mobile-replay:schema:build:json && git diff --exit-code

- name: Set up Python (for OpenAPI schema generation)
uses: actions/setup-python@v5
with:
python-version: 3.12.11

- name: Install uv
uses: astral-sh/setup-uv@3259c6206f993105e3a61b142c2d97bf4b9ef83d # v7.1.0
with:
enable-cache: true
version: 0.8.19

- name: Install Python dependencies
run: |
UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --frozen --dev

- name: Check if OpenAPI types are up to date
run: |
./bin/build-openapi-schema.sh
node frontend/bin/generate-openapi-types.mjs
git diff --exit-code

jest:
runs-on: depot-ubuntu-24.04
needs: changes
Expand Down
4 changes: 3 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@
"common/hogvm/__tests__/__snapshots__",
"common/hogvm/__tests__/__snapshots__/**",
"frontend/src/queries/validators.js",
"frontend/src/generated/**",
"products/mcp/**/generated.ts",
"products/mcp/schema/tool-inputs.json",
"products/mcp/python"
"products/mcp/python",
"products/**/frontend/generated/**"
],
"rules": {
"no-constant-condition": "off",
Expand Down
13 changes: 13 additions & 0 deletions bin/build-openapi-schema.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Generate OpenAPI schema from Django backend
# Usage: ./bin/build-openapi-schema.sh

set -e

SCHEMA_PATH="frontend/src/types/api/openapi.json"
mkdir -p "$(dirname "$SCHEMA_PATH")"

# Include internal endpoints - these are used by the frontend
OPENAPI_INCLUDE_INTERNAL=1 python manage.py spectacular --file "$SCHEMA_PATH" --format openapi-json

echo "OpenAPI schema written to $SCHEMA_PATH"
11 changes: 11 additions & 0 deletions common/hogli/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,12 @@ build:
build:schema-latest-versions:
bin_script: build-schema-latest-versions.py
description: Extract latest schema versions and generate frontend JSON config
build:openapi-schema:
bin_script: build-openapi-schema.sh
description: Generate OpenAPI schema from the Django backend
build:openapi-types:
cmd: pnpm --filter=@posthog/frontend run openapi:types
description: Generate TypeScript API types from the backend OpenAPI schema
build:taxonomy-json:
bin_script: build-taxonomy-json.py
description: Generate core filter definitions taxonomy JSON for frontend
Expand All @@ -270,6 +276,11 @@ build:
- build:schema-python
- build:schema-latest-versions
description: Generate all schema definitions (frontend JSON, Python, latest versions)
build:openapi:
steps:
- build:openapi-schema
- build:openapi-types
description: Generate OpenAPI schema and TypeScript types from the backend
build:grammar:
cmd: pnpm grammar:build
description: Generate HogQL grammar definitions (Python and C++)
Expand Down
2 changes: 2 additions & 0 deletions ee/api/billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import requests
import structlog
import posthoganalytics
from drf_spectacular.utils import extend_schema
from rest_framework import permissions, serializers, status, viewsets
from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError
from rest_framework.request import Request
Expand Down Expand Up @@ -92,6 +93,7 @@ def validate_end_date(self, value: Optional[str]) -> Optional[str]:
return self._parse_date(value, "end_date")


@extend_schema(tags=["billing"])
class BillingViewset(TeamAndOrgViewSetMixin, viewsets.GenericViewSet):
serializer_class = BillingSerializer
param_derived_from_user_current_team = "team_id"
Expand Down
2 changes: 2 additions & 0 deletions ee/api/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import structlog
import posthoganalytics
from asgiref.sync import async_to_sync as asgi_async_to_sync
from drf_spectacular.utils import extend_schema
from prometheus_client import Histogram
from rest_framework import exceptions, serializers, status
from rest_framework.decorators import action
Expand Down Expand Up @@ -113,6 +114,7 @@ def validate(self, data):
return data


@extend_schema(tags=["max"])
class ConversationViewSet(TeamAndOrgViewSetMixin, ListModelMixin, RetrieveModelMixin, GenericViewSet):
scope_object = "INTERNAL"
serializer_class = ConversationSerializer
Expand Down
2 changes: 2 additions & 0 deletions ee/api/rbac/role.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from django.db import IntegrityError

from drf_spectacular.utils import extend_schema
from rest_framework import mixins, serializers, viewsets
from rest_framework.permissions import SAFE_METHODS, BasePermission

Expand Down Expand Up @@ -77,6 +78,7 @@ def get_is_default(self, role: Role):
return organization.default_role_id == role.id


@extend_schema(tags=["core"])
class RoleViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet):
scope_object = "organization"
serializer_class = RoleSerializer
Expand Down
2 changes: 2 additions & 0 deletions ee/api/subscription.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from django.http import HttpRequest, JsonResponse

import jwt
from drf_spectacular.utils import extend_schema
from rest_framework import serializers, viewsets
from rest_framework.exceptions import ValidationError

Expand Down Expand Up @@ -131,6 +132,7 @@ def update(self, instance: Subscription, validated_data: dict, *args, **kwargs)
return instance


@extend_schema(tags=["core"])
class SubscriptionViewSet(TeamAndOrgViewSetMixin, ForbidDestroyModel, viewsets.ModelViewSet):
scope_object = "subscription"
queryset = Subscription.objects.all()
Expand Down
2 changes: 2 additions & 0 deletions ee/clickhouse/views/experiment_holdouts.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from django.db.models.signals import pre_delete
from django.dispatch import receiver

from drf_spectacular.utils import extend_schema
from rest_framework import serializers, viewsets
from rest_framework.exceptions import ValidationError
from rest_framework.request import Request
Expand Down Expand Up @@ -96,6 +97,7 @@ def update(self, instance: ExperimentHoldout, validated_data):
return super().update(instance, validated_data)


@extend_schema(tags=["experiments"])
class ExperimentHoldoutViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet):
scope_object = "experiment"
queryset = ExperimentHoldout.objects.prefetch_related("created_by").all()
Expand Down
2 changes: 2 additions & 0 deletions ee/clickhouse/views/experiments.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from django.db.models import Case, F, Prefetch, Q, QuerySet, Value, When
from django.db.models.functions import Now

from drf_spectacular.utils import extend_schema
from rest_framework import serializers, viewsets
from rest_framework.exceptions import ValidationError
from rest_framework.request import Request
Expand Down Expand Up @@ -509,6 +510,7 @@ class ExperimentStatus(str, Enum):
ALL = "all"


@extend_schema(tags=["experiments"])
class EnterpriseExperimentsViewSet(
ForbidDestroyModel, TeamAndOrgViewSetMixin, AccessControlViewSetMixin, viewsets.ModelViewSet
):
Expand Down
1 change: 1 addition & 0 deletions ee/clickhouse/views/groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ class Meta:
fields = ["group_type_index", "group_key", "group_properties"]


@extend_schema(tags=["core"])
class GroupsViewSet(TeamAndOrgViewSetMixin, mixins.ListModelMixin, mixins.CreateModelMixin, viewsets.GenericViewSet):
scope_object = "group"
queryset = Group.objects.all()
Expand Down
140 changes: 140 additions & 0 deletions frontend/bin/find-untagged-serializers.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#!/usr/bin/env node
/* eslint-disable no-console */
/**
* Find serializers that might need @extend_schema tags.
*
* Looks at manually-written TypeScript types and finds corresponding
* Django serializers that aren't yet tagged for OpenAPI generation.
*/
import { execSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

const __dirname = path.dirname(fileURLToPath(import.meta.url))
const frontendRoot = path.resolve(__dirname, '..')
const repoRoot = path.resolve(frontendRoot, '..')

// Files containing manual types that might correspond to API responses
const MANUAL_TYPE_FILES = ['frontend/src/types.ts', 'frontend/src/lib/components/Errors/types.ts']

// Extract type names from TypeScript files
function extractTypeNames(filePath) {
const fullPath = path.resolve(repoRoot, filePath)
if (!fs.existsSync(fullPath)) {
return []
}
const content = fs.readFileSync(fullPath, 'utf8')
const matches = [...content.matchAll(/^export (?:interface|type) (\w+)/gm)]
return matches.map((m) => m[1])
}

// Find serializers in Python code
function findSerializers() {
try {
const result = execSync(
`rg "class (\\w+)Serializer.*:" posthog/ products/ ee/ --glob "*.py" -o --no-heading 2>/dev/null || true`,
{ encoding: 'utf8', cwd: repoRoot }
)
const serializers = new Map()
for (const line of result.trim().split('\n')) {
if (!line) {
continue
}
const match = line.match(/^(.+):class (\w+)Serializer/)
if (match) {
const [, filePath, name] = match
serializers.set(name, filePath)
}
}
return serializers
} catch {
return new Map()
}
}

// Check if a file has @extend_schema with tags
function hasExtendSchemaTags(filePath) {
try {
const fullPath = path.resolve(repoRoot, filePath)
const content = fs.readFileSync(fullPath, 'utf8')
return content.includes('@extend_schema') && content.includes('tags=')
} catch {
return false
}
}

// Normalize type name to match serializer naming
function normalizeTypeName(name) {
// Remove common suffixes
return name
.replace(/Type$/, '')
.replace(/Response$/, '')
.replace(/Basic$/, '')
}

// Main
console.log('🔍 Finding manual types that might need serializer tags...\n')

const allTypeNames = new Set()
for (const file of MANUAL_TYPE_FILES) {
const names = extractTypeNames(file)
for (const name of names) {
allTypeNames.add(name)
}
}

console.log(` Found ${allTypeNames.size} manual types in ${MANUAL_TYPE_FILES.length} files`)

const serializers = findSerializers()
console.log(` Found ${serializers.size} serializers in Python code\n`)

// Find matches
const matches = []
for (const typeName of allTypeNames) {
const normalized = normalizeTypeName(typeName)
if (serializers.has(normalized)) {
const serializerFile = serializers.get(normalized)
const hasTag = hasExtendSchemaTags(serializerFile)
matches.push({
typeName,
serializerName: `${normalized}Serializer`,
serializerFile,
hasTag,
})
}
}

// Report
const untagged = matches.filter((m) => !m.hasTag)
const tagged = matches.filter((m) => m.hasTag)

if (untagged.length > 0) {
console.log('⚠️ Serializers without @extend_schema tags (potential candidates):')
for (const { typeName, serializerName, serializerFile } of untagged) {
console.log(` ${typeName} → ${serializerName}`)
console.log(` └─ ${serializerFile}`)
}
console.log('')
console.log(' To enable type generation, add to the ViewSet:')
console.log(' @extend_schema(tags=["your_product"])')
}

if (tagged.length > 0) {
console.log('')
console.log('✅ Serializers already tagged (types may be redundant):')
for (const { typeName, serializerName, serializerFile } of tagged) {
console.log(` ${typeName} → ${serializerName}`)
console.log(` └─ ${serializerFile}`)
}
}

if (matches.length === 0) {
console.log('No direct matches found between manual types and serializers.')
console.log('This might mean:')
console.log(' - Types use different naming conventions')
console.log(' - Types are for non-API data (UI state, etc.)')
}

console.log('')
console.log(`Summary: ${untagged.length} untagged, ${tagged.length} tagged`)
Loading