From cc1f5ca0b644232906f20c07d7934e6fe60a95e6 Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Wed, 3 Jun 2026 12:07:35 -0700 Subject: [PATCH] feat(api): tighten Response[T] on 14 endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three patterns, source-typing only — no cast(), no behavior change: annotation-only where the body matched the declared T; union with ValidationErrorResponse + as_validation_errors() for the `Response(serializer.errors, 400)` path; one Serializer[T] subscript on ExternalActorSerializer to unlock typed-overload resolution. Round 5 of the rollout — same shape as #116717 / #116736 / #116743. --- .../api/endpoints/organization_releases.py | 8 +++++-- .../endpoints/discover_saved_queries.py | 11 ++++++--- .../endpoints/discover_saved_query_detail.py | 24 ++++++++++++++----- .../api/endpoints/external_team_details.py | 19 +++++++++++---- .../api/endpoints/external_team_index.py | 22 +++++++++++++---- .../api/endpoints/external_user_details.py | 17 +++++++++---- .../api/endpoints/external_user_index.py | 22 +++++++++++++---- .../api/serializers/models/external_actor.py | 2 +- .../endpoints/organization_group_index.py | 2 +- .../issues/endpoints/project_ownership.py | 18 ++++++++++---- .../endpoints/organization_monitor_index.py | 4 +++- .../endpoints/organization_release_commits.py | 4 +++- .../endpoints/project_release_commits.py | 2 +- 13 files changed, 116 insertions(+), 39 deletions(-) diff --git a/src/sentry/api/endpoints/organization_releases.py b/src/sentry/api/endpoints/organization_releases.py index ebe124bb305687..1393045e0a2f39 100644 --- a/src/sentry/api/endpoints/organization_releases.py +++ b/src/sentry/api/endpoints/organization_releases.py @@ -362,7 +362,9 @@ def get_projects(self, request: Request, organization, project_ids=None, project }, examples=ReleaseExamples.LIST_ORGANIZATION_RELEASES, ) - def get(self, request: Request, organization: Organization) -> Response: + def get( + self, request: Request, organization: Organization + ) -> Response[list[ReleaseSerializerResponse]]: """ Return a list of releases for a given organization, sorted by most recent. """ @@ -934,7 +936,9 @@ class OrganizationReleasesStatsEndpoint(OrganizationReleasesBaseEndpoint): }, examples=ReleaseExamples.LIST_RELEASE_TIMESERIES, ) - def get(self, request: Request, organization: Organization) -> Response: + def get( + self, request: Request, organization: Organization + ) -> Response[list[OrganizationReleaseTimeseriesData]]: """ Return a minimal list of an organization's releases (version and date only), sorted by most recent. Intended for building release timeseries, such as diff --git a/src/sentry/discover/endpoints/discover_saved_queries.py b/src/sentry/discover/endpoints/discover_saved_queries.py index 3c3d31ffe40818..4ce247598374ec 100644 --- a/src/sentry/discover/endpoints/discover_saved_queries.py +++ b/src/sentry/discover/endpoints/discover_saved_queries.py @@ -25,6 +25,7 @@ GlobalParams, VisibilityParams, ) +from sentry.apidocs.response_types import ValidationErrorResponse, as_validation_errors from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.discover.endpoints.bases import ( DiscoverSavedQueryPermission, @@ -170,7 +171,9 @@ def data_fn(offset, limit): }, examples=DiscoverExamples.DISCOVER_SAVED_QUERY_POST_RESPONSE, ) - def post(self, request: Request, organization) -> Response: + def post( + self, request: Request, organization + ) -> Response[DiscoverSavedQueryResponse] | Response[ValidationErrorResponse]: """ Create a new saved query for the given organization. """ @@ -190,7 +193,7 @@ def post(self, request: Request, organization) -> Response: ) if not serializer.is_valid(): - return Response(serializer.errors, status=400) + return Response(as_validation_errors(serializer), status=400) data = serializer.validated_data user_selected_dataset = data["query_dataset"] != DiscoverSavedQueryTypes.DISCOVER @@ -211,4 +214,6 @@ def post(self, request: Request, organization) -> Response: model.set_projects(data["project_ids"]) - return Response(serialize(model), status=201) + return Response( + serialize(model, serializer=DiscoverSavedQueryModelSerializer()), status=201 + ) diff --git a/src/sentry/discover/endpoints/discover_saved_query_detail.py b/src/sentry/discover/endpoints/discover_saved_query_detail.py index 77703a1f165ef1..5474f99d852ca8 100644 --- a/src/sentry/discover/endpoints/discover_saved_query_detail.py +++ b/src/sentry/discover/endpoints/discover_saved_query_detail.py @@ -12,7 +12,10 @@ from sentry.api.bases import NoProjects, OrganizationEndpoint from sentry.api.exceptions import ResourceDoesNotExist from sentry.api.serializers import serialize -from sentry.api.serializers.models.discoversavedquery import DiscoverSavedQueryModelSerializer +from sentry.api.serializers.models.discoversavedquery import ( + DiscoverSavedQueryModelSerializer, + DiscoverSavedQueryResponse, +) from sentry.apidocs.constants import ( RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN, @@ -21,6 +24,7 @@ ) from sentry.apidocs.examples.discover_saved_query_examples import DiscoverExamples from sentry.apidocs.parameters import DiscoverSavedQueryParams, GlobalParams +from sentry.apidocs.response_types import ValidationErrorResponse, as_validation_errors from sentry.discover.endpoints.bases import DiscoverSavedQueryPermission from sentry.discover.endpoints.serializers import DiscoverSavedQuerySerializer from sentry.discover.models import DatasetSourcesTypes, DiscoverSavedQuery, DiscoverSavedQueryTypes @@ -72,7 +76,9 @@ def has_feature(self, organization, request): }, examples=DiscoverExamples.DISCOVER_SAVED_QUERY_GET_RESPONSE, ) - def get(self, request: Request, organization, query) -> Response: + def get( + self, request: Request, organization, query: DiscoverSavedQuery + ) -> Response[DiscoverSavedQueryResponse]: """ Retrieve a saved query. """ @@ -81,7 +87,9 @@ def get(self, request: Request, organization, query) -> Response: self.check_object_permissions(request, query) - return Response(serialize(query), status=200) + return Response( + serialize(query, serializer=DiscoverSavedQueryModelSerializer()), status=200 + ) @extend_schema( operation_id="Edit an Organization's Discover Saved Query", @@ -95,7 +103,9 @@ def get(self, request: Request, organization, query) -> Response: }, examples=DiscoverExamples.DISCOVER_SAVED_QUERY_GET_RESPONSE, ) - def put(self, request: Request, organization: Organization, query) -> Response: + def put( + self, request: Request, organization: Organization, query: DiscoverSavedQuery + ) -> Response[DiscoverSavedQueryResponse] | Response[ValidationErrorResponse]: """ Modify a saved query. """ @@ -116,7 +126,7 @@ def put(self, request: Request, organization: Organization, query) -> Response: context={"params": params, "organization": organization, "user": request.user}, ) if not serializer.is_valid(): - return Response(serializer.errors, status=400) + return Response(as_validation_errors(serializer), status=400) data = serializer.validated_data user_selected_dataset = data["query_dataset"] != DiscoverSavedQueryTypes.DISCOVER @@ -136,7 +146,9 @@ def put(self, request: Request, organization: Organization, query) -> Response: query.set_projects(data["project_ids"]) - return Response(serialize(query), status=200) + return Response( + serialize(query, serializer=DiscoverSavedQueryModelSerializer()), status=200 + ) @extend_schema( operation_id="Delete an Organization's Discover Saved Query", diff --git a/src/sentry/integrations/api/endpoints/external_team_details.py b/src/sentry/integrations/api/endpoints/external_team_details.py index 1659e6172a4dae..8779cb0358852f 100644 --- a/src/sentry/integrations/api/endpoints/external_team_details.py +++ b/src/sentry/integrations/api/endpoints/external_team_details.py @@ -14,11 +14,15 @@ from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN, RESPONSE_NO_CONTENT from sentry.apidocs.examples.integration_examples import IntegrationExamples from sentry.apidocs.parameters import GlobalParams, OrganizationParams +from sentry.apidocs.response_types import ValidationErrorResponse, as_validation_errors from sentry.integrations.api.bases.external_actor import ( ExternalActorEndpointMixin, ExternalTeamSerializer, ) -from sentry.integrations.api.serializers.models.external_actor import ExternalActorSerializer +from sentry.integrations.api.serializers.models.external_actor import ( + ExternalActorResponse, + ExternalActorSerializer, +) from sentry.integrations.models.external_actor import ExternalActor from sentry.models.team import Team @@ -66,7 +70,9 @@ def convert_args( }, examples=IntegrationExamples.EXTERNAL_TEAM_CREATE, ) - def put(self, request: Request, team: Team, external_team: ExternalActor) -> Response: + def put( + self, request: Request, team: Team, external_team: ExternalActor + ) -> Response[ExternalActorResponse] | Response[ValidationErrorResponse]: """ Update a team in an external provider that is currently linked to a Sentry team. """ @@ -82,13 +88,16 @@ def put(self, request: Request, team: Team, external_team: ExternalActor) -> Res context={"organization": team.organization}, ) if serializer.is_valid(): - updated_external_team = serializer.save() + updated_external_team: ExternalActor = serializer.save() return Response( - serialize(updated_external_team, request.user), status=status.HTTP_200_OK + serialize( + updated_external_team, request.user, serializer=ExternalActorSerializer() + ), + status=status.HTTP_200_OK, ) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response(as_validation_errors(serializer), status=status.HTTP_400_BAD_REQUEST) @extend_schema( operation_id="Delete an External Team", diff --git a/src/sentry/integrations/api/endpoints/external_team_index.py b/src/sentry/integrations/api/endpoints/external_team_index.py index cfbcfa00ec871f..9763011802c7cc 100644 --- a/src/sentry/integrations/api/endpoints/external_team_index.py +++ b/src/sentry/integrations/api/endpoints/external_team_index.py @@ -13,11 +13,16 @@ from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN from sentry.apidocs.examples.integration_examples import IntegrationExamples from sentry.apidocs.parameters import GlobalParams +from sentry.apidocs.response_types import ValidationErrorResponse, as_validation_errors from sentry.integrations.api.bases.external_actor import ( ExternalActorEndpointMixin, ExternalTeamSerializer, ) -from sentry.integrations.api.serializers.models.external_actor import ExternalActorSerializer +from sentry.integrations.api.serializers.models.external_actor import ( + ExternalActorResponse, + ExternalActorSerializer, +) +from sentry.integrations.models.external_actor import ExternalActor from sentry.models.team import Team logger = logging.getLogger(__name__) @@ -43,7 +48,9 @@ class ExternalTeamEndpoint(TeamEndpoint, ExternalActorEndpointMixin): }, examples=IntegrationExamples.EXTERNAL_TEAM_CREATE, ) - def post(self, request: Request, team: Team) -> Response: + def post( + self, request: Request, team: Team + ) -> Response[ExternalActorResponse] | Response[ValidationErrorResponse]: """ Link a team from an external provider to a Sentry team. """ @@ -56,8 +63,15 @@ def post(self, request: Request, team: Team) -> Response: data={**request.data, "team_id": team.id}, context={"organization": team.organization} ) if not serializer.is_valid(): - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response(as_validation_errors(serializer), status=status.HTTP_400_BAD_REQUEST) + external_team: ExternalActor + created: bool external_team, created = serializer.save() status_code = status.HTTP_201_CREATED if created else status.HTTP_200_OK - return Response(serialize(external_team, request.user, key="team"), status=status_code) + return Response( + serialize( + external_team, request.user, key="team", serializer=ExternalActorSerializer() + ), + status=status_code, + ) diff --git a/src/sentry/integrations/api/endpoints/external_user_details.py b/src/sentry/integrations/api/endpoints/external_user_details.py index 8d29ad8b5824b3..ac41c46d680dd7 100644 --- a/src/sentry/integrations/api/endpoints/external_user_details.py +++ b/src/sentry/integrations/api/endpoints/external_user_details.py @@ -17,12 +17,16 @@ from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN, RESPONSE_NO_CONTENT from sentry.apidocs.examples.integration_examples import IntegrationExamples from sentry.apidocs.parameters import GlobalParams, OrganizationParams +from sentry.apidocs.response_types import ValidationErrorResponse, as_validation_errors from sentry.integrations.api.bases.external_actor import ( ExternalActorEndpointMixin, ExternalUserPermission, ExternalUserSerializer, ) -from sentry.integrations.api.serializers.models.external_actor import ExternalActorSerializer +from sentry.integrations.api.serializers.models.external_actor import ( + ExternalActorResponse, + ExternalActorSerializer, +) from sentry.integrations.models.external_actor import ExternalActor from sentry.models.organization import Organization @@ -67,7 +71,7 @@ def convert_args( ) def put( self, request: Request, organization: Organization, external_user: ExternalActor - ) -> Response: + ) -> Response[ExternalActorResponse] | Response[ValidationErrorResponse]: """ Update a user in an external provider that is currently linked to a Sentry user. """ @@ -80,13 +84,16 @@ def put( partial=True, ) if serializer.is_valid(): - updated_external_user = serializer.save() + updated_external_user: ExternalActor = serializer.save() return Response( - serialize(updated_external_user, request.user), status=status.HTTP_200_OK + serialize( + updated_external_user, request.user, serializer=ExternalActorSerializer() + ), + status=status.HTTP_200_OK, ) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response(as_validation_errors(serializer), status=status.HTTP_400_BAD_REQUEST) @extend_schema( operation_id="Delete an External User", diff --git a/src/sentry/integrations/api/endpoints/external_user_index.py b/src/sentry/integrations/api/endpoints/external_user_index.py index 05016e87d822bf..1562afef636a79 100644 --- a/src/sentry/integrations/api/endpoints/external_user_index.py +++ b/src/sentry/integrations/api/endpoints/external_user_index.py @@ -13,12 +13,17 @@ from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN from sentry.apidocs.examples.integration_examples import IntegrationExamples from sentry.apidocs.parameters import GlobalParams +from sentry.apidocs.response_types import ValidationErrorResponse, as_validation_errors from sentry.integrations.api.bases.external_actor import ( ExternalActorEndpointMixin, ExternalUserPermission, ExternalUserSerializer, ) -from sentry.integrations.api.serializers.models.external_actor import ExternalActorSerializer +from sentry.integrations.api.serializers.models.external_actor import ( + ExternalActorResponse, + ExternalActorSerializer, +) +from sentry.integrations.models.external_actor import ExternalActor from sentry.models.organization import Organization logger = logging.getLogger(__name__) @@ -45,7 +50,9 @@ class ExternalUserEndpoint(OrganizationEndpoint, ExternalActorEndpointMixin): }, examples=IntegrationExamples.EXTERNAL_USER_CREATE, ) - def post(self, request: Request, organization: Organization) -> Response: + def post( + self, request: Request, organization: Organization + ) -> Response[ExternalActorResponse] | Response[ValidationErrorResponse]: """ Link a user from an external provider to a Sentry user. """ @@ -55,8 +62,15 @@ def post(self, request: Request, organization: Organization) -> Response: data=request.data, context={"organization": organization} ) if not serializer.is_valid(): - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response(as_validation_errors(serializer), status=status.HTTP_400_BAD_REQUEST) + external_user: ExternalActor + created: bool external_user, created = serializer.save() status_code = status.HTTP_201_CREATED if created else status.HTTP_200_OK - return Response(serialize(external_user, request.user, key="user"), status=status_code) + return Response( + serialize( + external_user, request.user, key="user", serializer=ExternalActorSerializer() + ), + status=status_code, + ) diff --git a/src/sentry/integrations/api/serializers/models/external_actor.py b/src/sentry/integrations/api/serializers/models/external_actor.py index be53368f5757f5..573e63281418d1 100644 --- a/src/sentry/integrations/api/serializers/models/external_actor.py +++ b/src/sentry/integrations/api/serializers/models/external_actor.py @@ -24,7 +24,7 @@ class ExternalActorResponse(ExternalActorResponseOptional): @register(ExternalActor) -class ExternalActorSerializer(Serializer): +class ExternalActorSerializer(Serializer[ExternalActorResponse]): def get_attrs( self, item_list: Sequence[ExternalActor], diff --git a/src/sentry/issues/endpoints/organization_group_index.py b/src/sentry/issues/endpoints/organization_group_index.py index d29d3df89c2fa0..3b46c9e7c7b302 100644 --- a/src/sentry/issues/endpoints/organization_group_index.py +++ b/src/sentry/issues/endpoints/organization_group_index.py @@ -487,7 +487,7 @@ def get(self, request: Request, organization: Organization) -> Response: examples=IssueExamples.ORGANIZATION_GROUP_INDEX_PUT, ) @track_slo_response("workflow") - def put(self, request: Request, organization: Organization) -> Response: + def put(self, request: Request, organization: Organization) -> Response[MutateIssueResponse]: projects = self.get_projects(request, organization) search_fn = functools.partial( diff --git a/src/sentry/issues/endpoints/project_ownership.py b/src/sentry/issues/endpoints/project_ownership.py index 913568a78d0bc1..e99bdb97b3a2db 100644 --- a/src/sentry/issues/endpoints/project_ownership.py +++ b/src/sentry/issues/endpoints/project_ownership.py @@ -13,10 +13,14 @@ from sentry.api.base import cell_silo_endpoint from sentry.api.bases.project import ProjectEndpoint, ProjectOwnershipPermission from sentry.api.serializers import serialize -from sentry.api.serializers.models.projectownership import ProjectOwnershipSerializer +from sentry.api.serializers.models.projectownership import ( + ProjectOwnershipResponse, + ProjectOwnershipSerializer, +) from sentry.apidocs.constants import RESPONSE_BAD_REQUEST from sentry.apidocs.examples import ownership_examples from sentry.apidocs.parameters import GlobalParams +from sentry.apidocs.response_types import ValidationErrorResponse, as_validation_errors from sentry.issues.ownership.grammar import CODEOWNERS, create_schema_from_issue_owners from sentry.models.organizationmemberteam import OrganizationMemberTeam from sentry.models.project import Project @@ -302,7 +306,9 @@ def get(self, request: Request, project) -> Response: }, examples=ownership_examples.UPDATE_PROJECT_OWNERSHIP, ) - def put(self, request: Request, project) -> Response: + def put( + self, request: Request, project: Project + ) -> Response[ProjectOwnershipResponse] | Response[ValidationErrorResponse]: """ Updates ownership configurations for a project. Note that only the attributes submitted are modified. @@ -323,7 +329,7 @@ def put(self, request: Request, project) -> Response: context={"ownership": self.get_ownership(project), "request": request}, ) if serializer.is_valid(): - ownership = serializer.save() + ownership: ProjectOwnership = serializer.save() change_data = {**serializer.validated_data} # Ownership rules can be large (3 MB) and we don't want to store them in the audit log @@ -341,5 +347,7 @@ def put(self, request: Request, project) -> Response: data={**change_data, **project.get_audit_log_data()}, ) ownership_rule_created.send_robust(project=project, sender=self.__class__) - return Response(serialize(ownership, request.user)) - return Response(serializer.errors, status=400) + return Response( + serialize(ownership, request.user, serializer=ProjectOwnershipSerializer()) + ) + return Response(as_validation_errors(serializer), status=400) diff --git a/src/sentry/monitors/endpoints/organization_monitor_index.py b/src/sentry/monitors/endpoints/organization_monitor_index.py index 8ede95fe18e224..2d622356531902 100644 --- a/src/sentry/monitors/endpoints/organization_monitor_index.py +++ b/src/sentry/monitors/endpoints/organization_monitor_index.py @@ -281,7 +281,9 @@ def get( 404: RESPONSE_NOT_FOUND, }, ) - def post(self, request: AuthenticatedHttpRequest, organization) -> Response: + def post( + self, request: AuthenticatedHttpRequest, organization + ) -> Response[MonitorSerializerResponse]: """ Create a new monitor. """ diff --git a/src/sentry/releases/endpoints/organization_release_commits.py b/src/sentry/releases/endpoints/organization_release_commits.py index d8fd1dfb9eeb0d..5862c32d50f40c 100644 --- a/src/sentry/releases/endpoints/organization_release_commits.py +++ b/src/sentry/releases/endpoints/organization_release_commits.py @@ -42,7 +42,9 @@ class OrganizationReleaseCommitsEndpoint(OrganizationReleasesBaseEndpoint): }, examples=ReleaseExamples.LIST_RELEASE_COMMITS, ) - def get(self, request: Request, organization, version) -> Response: + def get( + self, request: Request, organization, version + ) -> Response[list[CommitSerializerResponse]]: """ Retrieve a list of commits for a given release. """ diff --git a/src/sentry/releases/endpoints/project_release_commits.py b/src/sentry/releases/endpoints/project_release_commits.py index 1ba168a828b2c7..01e9441d86b813 100644 --- a/src/sentry/releases/endpoints/project_release_commits.py +++ b/src/sentry/releases/endpoints/project_release_commits.py @@ -46,7 +46,7 @@ class ProjectReleaseCommitsEndpoint(ProjectEndpoint): }, examples=ReleaseExamples.LIST_RELEASE_COMMITS, ) - def get(self, request: Request, project, version) -> Response: + def get(self, request: Request, project, version) -> Response[list[CommitSerializerResponse]]: """ Retrieve a list of commits for a given release. """