Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED
from sentry.apidocs.examples.trace_item_attribute_examples import TraceItemAttributeExamples
from sentry.apidocs.parameters import CursorQueryParam, GlobalParams
from sentry.apidocs.response_types import ValidationErrorResponse, as_validation_errors
from sentry.apidocs.utils import inline_sentry_response_serializer
from sentry.auth.staff import is_active_staff
from sentry.auth.superuser import is_active_superuser
Expand Down Expand Up @@ -362,7 +363,9 @@ class OrganizationTraceItemAttributesEndpoint(OrganizationTraceItemAttributesEnd
},
examples=TraceItemAttributeExamples.LIST_TRACE_ITEM_ATTRIBUTES,
)
def get(self, request: Request, organization: Organization) -> Response:
def get(
self, request: Request, organization: Organization
) -> Response[list[TraceItemAttributeKey]] | Response[ValidationErrorResponse]:
"""
List the attribute keys available on a given trace item dataset (spans, logs,
trace metrics, etc.), with optional substring and structured filtering.
Expand All @@ -372,7 +375,7 @@ def get(self, request: Request, organization: Organization) -> Response:

serializer = OrganizationTraceItemAttributesEndpointSerializer(data=request.GET)
if not serializer.is_valid():
return Response(serializer.errors, status=400)
return Response(as_validation_errors(serializer), status=400)

try:
snuba_params = self.get_snuba_params(request, organization)
Expand Down
4 changes: 2 additions & 2 deletions src/sentry/api/endpoints/project_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,12 @@ class ProjectFiltersEndpoint(ProjectEndpoint):
},
examples=ProjectExamples.GET_PROJECT_FILTERS,
)
def get(self, request: Request, project) -> Response:
def get(self, request: Request, project) -> Response[list[ProjectFilterResponse]]:
"""
Retrieve a list of filters for a given project.
`active` will be either a boolean or a list for the legacy browser filters.
"""
results = []
results: list[ProjectFilterResponse] = []
for flt in inbound_filters.get_all_filter_specs():
results.append(
{
Expand Down
62 changes: 52 additions & 10 deletions src/sentry/apidocs/response_types.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,28 @@
"""Shared TypedDicts for endpoint Response annotations.
"""Shared response shapes and helpers for endpoint Response annotations.

This module holds TypedDict shapes that recur across multiple endpoints.
It is *not* authoritative — endpoints whose error/response shapes don't
match anything here are expected to declare local TypedDicts in their
own files. The structural linter at
`sentry.apidocs._check_response_annotation_matches_schema` is name-agnostic;
it does not special-case any TypedDict name in this module.
This module holds TypedDicts, type aliases, and small helpers that recur
across multiple endpoints in the Response[T] typing rollout. It is *not*
authoritative — endpoints whose error/response shapes don't match anything
here are expected to declare local types in their own files. The structural
linter at `sentry.apidocs._check_response_annotation_matches_schema` is
name-agnostic; it does not special-case any name in this module.

`DetailResponse` is included because DRF's exception handler renders every
uncaught `APIException` subclass as `{"detail": "..."}` and a non-trivial
number of endpoints return that shape inline. Other shapes can graduate
into this module as they emerge from real usage.
number of endpoints return that shape inline.

`ValidationErrorResponse` + `as_validation_errors()` cover the parallel
case for DRF `Response(serializer.errors, status=400)` paths.

The module is named `response_types` rather than `types` to avoid shadowing
Python's stdlib `types` module under subprocess tooling (e.g. some prek hooks).
"""

from __future__ import annotations

from typing import TypedDict
from typing import Any, TypeAlias, TypedDict

from rest_framework import serializers


class DetailResponse(TypedDict):
Expand All @@ -31,3 +35,41 @@ class DetailResponse(TypedDict):
"""

detail: str


ValidationErrorResponse: TypeAlias = dict[str, Any]
"""DRF's validation-error body shape: `{field_name: <errors>, ...}`.

DRF emits a few different value shapes here depending on the serializer:
- Flat field errors: `{"field": ["error msg", ...]}`
- Nested (e.g. `Serializer` with a nested `Serializer` field):
`{"field": {"nested_field": ["error msg", ...]}}`
- Non-field errors (raised in `validate()`):
`{"non_field_errors": ["error msg", ...]}`

The alias is intentionally `dict[str, Any]` — narrower types like
`dict[str, list[str]]` collapse the nested-dict case and lose the error
messages at runtime. The runtime value of `serializer.errors` is a
`ReturnDict[Any, Any]` that mypy can't structurally match against any
typed `Response[T]` union arm, so use this alias as the union arm:

def post(...) -> Response[FooResponse] | Response[ValidationErrorResponse]:

and produce the body via `as_validation_errors(serializer)` below.
"""


def as_validation_errors(
serializer: serializers.Serializer[Any],
) -> ValidationErrorResponse:
"""Project a DRF `Serializer.errors` ReturnDict into a structurally typed
`dict[str, Any]` so a `Response[ValidationErrorResponse]` union arm is
satisfied without `cast()`. The DRF error structure (flat or nested) is
preserved verbatim — only the static type is narrowed.

Use immediately after `not serializer.is_valid()`:

if not serializer.is_valid():
return Response(as_validation_errors(serializer), status=400)
"""
return dict(serializer.errors)
15 changes: 10 additions & 5 deletions src/sentry/replays/endpoints/project_replay_jobs_delete.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND
from sentry.apidocs.examples.replay_examples import ReplayExamples
from sentry.apidocs.parameters import GlobalParams, ReplayParams
from sentry.apidocs.response_types import ValidationErrorResponse, as_validation_errors
from sentry.apidocs.utils import inline_sentry_response_serializer
from sentry.replays.endpoints.project_replay_endpoint import ProjectReplayEndpoint
from sentry.replays.models import ReplayDeletionJobModel
Expand Down Expand Up @@ -109,7 +110,7 @@ class ProjectReplayDeletionJobsIndexEndpoint(ProjectEndpoint):
},
examples=ReplayExamples.GET_REPLAY_DELETION_JOBS,
)
def get(self, request: Request, project) -> Response:
def get(self, request: Request, project) -> Response[ReplayDeletionJobListResponse]:
"""
Retrieve a collection of replay delete jobs.
"""
Expand Down Expand Up @@ -146,7 +147,9 @@ def get(self, request: Request, project) -> Response:
},
examples=ReplayExamples.CREATE_REPLAY_DELETION_JOB,
)
def post(self, request: Request, project) -> Response:
def post(
self, request: Request, project
) -> Response[ReplayDeletionJobDetailResponse] | Response[ValidationErrorResponse]:
"""
Create a new replay deletion job.
"""
Expand All @@ -155,7 +158,7 @@ def post(self, request: Request, project) -> Response:

serializer = ReplayDeletionJobCreateSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=400)
return Response(as_validation_errors(serializer), status=400)
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
azulus marked this conversation as resolved.

data = serializer.validated_data["data"]

Expand Down Expand Up @@ -186,7 +189,7 @@ def post(self, request: Request, project) -> Response:
)

response_data = serialize(job, request.user, ReplayDeletionJobSerializer())
response = {"data": response_data}
response: ReplayDeletionJobDetailResponse = {"data": response_data}

return Response(response, status=201)

Expand Down Expand Up @@ -215,7 +218,9 @@ class ProjectReplayDeletionJobDetailEndpoint(ProjectReplayEndpoint):
},
examples=ReplayExamples.GET_REPLAY_DELETION_JOB,
)
def get(self, request: Request, project, job_id: int) -> Response:
def get(
self, request: Request, project, job_id: int
) -> Response[ReplayDeletionJobDetailResponse]:
"""
Fetch a replay delete job instance.
"""
Expand Down
Loading