From a47311a2cd8aab4a91b6c2552c22e4d9c57e145a Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:29:52 +0000 Subject: [PATCH] feat(apidocs): Add S034 lint for undocumented inline 4xx responses Flag a 4xx Response that an @extend_schema(responses=...) method returns inline when that status isn't a key in responses=. Document the 4xx statuses the 43 existing violating methods already return, using the RESPONSE_* constants (adds RESPONSE_GONE for 410). No runtime behavior change. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- setup.cfg | 4 +- .../endpoints/organization_agent_handoff.py | 6 +- src/sentry/api/endpoints/auth_2fa.py | 23 ++- src/sentry/api/endpoints/auth_recovery.py | 7 +- src/sentry/api/endpoints/auth_register.py | 3 +- .../organization_agentic_onboarding.py | 20 ++- ...nization_agentic_onboarding_run_details.py | 11 +- .../endpoints/organization_events_facets.py | 4 +- .../endpoints/organization_events_stats.py | 4 +- .../organization_profiling_profiles.py | 1 + .../organization_trace_item_attributes.py | 1 + .../organization_trace_item_stats.py | 8 +- src/sentry/apidocs/constants.py | 3 + .../endpoints/organization_member_details.py | 3 + .../endpoints/organization_member_index.py | 10 +- .../core/endpoints/organization_projects.py | 1 + .../core/endpoints/organization_teams.py | 8 +- src/sentry/core/endpoints/project_details.py | 11 +- src/sentry/core/endpoints/scim/teams.py | 3 + src/sentry/core/endpoints/team_details.py | 2 + src/sentry/core/endpoints/team_projects.py | 1 + .../endpoints/organization_dashboards.py | 3 + .../organization_integrations_index.py | 2 + src/sentry/issues/endpoints/group_notes.py | 4 +- .../issues/endpoints/group_notes_details.py | 5 +- .../issues/endpoints/project_user_issue.py | 3 + .../snapshots/preprod_artifact_snapshot.py | 7 +- .../endpoints/organization_release_details.py | 4 + .../endpoints/organization_replay_count.py | 3 +- .../organization_seer_workflow_configs.py | 3 + .../organization_seer_workflow_runs.py | 2 + .../organization_detector_anomaly_data.py | 8 +- tests/tools/test_flake8_plugin.py | 168 ++++++++++++++++++ tools/api_guidelines/rules.py | 3 +- tools/flake8_plugin.py | 90 ++++++++++ 35 files changed, 412 insertions(+), 27 deletions(-) diff --git a/setup.cfg b/setup.cfg index 8a158cb0455d..8d9de827b07b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -72,9 +72,9 @@ per-file-ignores = src/sentry/testutils/helpers/options.py, tests/sentry/options/test_manager.py, tests/sentry/options/test_update_channels.py, tests/sentry/runner/commands/test_configoptions.py: S011 # session setup, a fake settings object, and a script run in a subprocess src/sentry/testutils/pytest/sentry.py, tests/sentry/runner/test_initializer.py, tests/sentry/test_devimports.py: S031 - # S021-S028 lint the shipped API surface and its lint infrastructure; test + # S021-S028 and S034 lint the shipped API surface and its lint infrastructure; test # modules deliberately contain the shapes they exercise - tests/*: S021, S022, S023, S024, S025, S026, S027, S028 + tests/*: S021, S022, S023, S024, S025, S026, S027, S028, S034 [flake8:local-plugins] paths = . diff --git a/src/sentry/agent_handoff/endpoints/organization_agent_handoff.py b/src/sentry/agent_handoff/endpoints/organization_agent_handoff.py index d6a9eeebf994..2e237b40c24c 100644 --- a/src/sentry/agent_handoff/endpoints/organization_agent_handoff.py +++ b/src/sentry/agent_handoff/endpoints/organization_agent_handoff.py @@ -19,6 +19,7 @@ from sentry.api.bases.organization import ControlSiloOrganizationEndpoint, OrganizationPermission from sentry.api.permissions import DisallowImpersonatedTokenCreation, SentryIsAuthenticated from sentry.api.serializers.rest_framework import CamelSnakeSerializer +from sentry.apidocs.constants import RESPONSE_FORBIDDEN from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.auth.principal import require_user from sentry.organizations.services.organization.model import ( @@ -126,7 +127,10 @@ class OrganizationAgentHandoffEndpoint(ControlSiloOrganizationEndpoint): @extend_schema( operation_id="Create a private agent onboarding prompt", request=AgentHandoffRequestSerializer, - responses={201: inline_sentry_response_serializer("AgentHandoff", AgentHandoffResponse)}, + responses={ + 201: inline_sentry_response_serializer("AgentHandoff", AgentHandoffResponse), + 403: RESPONSE_FORBIDDEN, + }, ) def post( self, diff --git a/src/sentry/api/endpoints/auth_2fa.py b/src/sentry/api/endpoints/auth_2fa.py index bc7aedaeaa5c..1ab76a6ab85e 100644 --- a/src/sentry/api/endpoints/auth_2fa.py +++ b/src/sentry/api/endpoints/auth_2fa.py @@ -19,6 +19,12 @@ serialize_auth_mfa_required, ) from sentry.api.serializers.rest_framework import CamelSnakeSerializer +from sentry.apidocs.constants import ( + RESPONSE_BAD_REQUEST, + RESPONSE_NOT_FOUND, + RESPONSE_TOO_MANY_REQUESTS, + RESPONSE_UNAUTHORIZED, +) from sentry.auth.authenticators.base import ( ActivationChallengeResult, ActivationMessageResult, @@ -100,7 +106,7 @@ class AuthTwoFactorEndpoint(Endpoint): @extend_schema( operation_id="Get available methods for a pending two-factor authentication login", - responses={200: AuthMfaRequiredSerializer}, + responses={200: AuthMfaRequiredSerializer, 404: RESPONSE_NOT_FOUND}, ) def get(self, request: Request) -> Response: user = auth.get_pending_2fa_user(request) @@ -117,7 +123,13 @@ def get(self, request: Request) -> Response: @extend_schema( operation_id="Complete a pending two-factor authentication login", request=AuthTwoFactorRequestSerializer, - responses={200: AuthSuccessSerializer, 403: AuthDetailSerializer}, + responses={ + 200: AuthSuccessSerializer, + 400: RESPONSE_BAD_REQUEST, + 401: RESPONSE_UNAUTHORIZED, + 403: AuthDetailSerializer, + 429: RESPONSE_TOO_MANY_REQUESTS, + }, ) def post(self, request: Request) -> Response: serializer = AuthTwoFactorRequestSerializer(data=request.data) @@ -208,7 +220,12 @@ class AuthTwoFactorChallengeEndpoint(Endpoint): @extend_schema( operation_id="Activate a two-factor authentication challenge", request=AuthTwoFactorChallengeRequestSerializer, - responses={200: AuthMfaChallengeSerializer}, + responses={ + 200: AuthMfaChallengeSerializer, + 400: RESPONSE_BAD_REQUEST, + 401: RESPONSE_UNAUTHORIZED, + 429: RESPONSE_TOO_MANY_REQUESTS, + }, ) def post(self, request: Request) -> Response: serializer = AuthTwoFactorChallengeRequestSerializer(data=request.data) diff --git a/src/sentry/api/endpoints/auth_recovery.py b/src/sentry/api/endpoints/auth_recovery.py index e0d48d4b367f..bcbea4086e1d 100644 --- a/src/sentry/api/endpoints/auth_recovery.py +++ b/src/sentry/api/endpoints/auth_recovery.py @@ -17,6 +17,7 @@ AuthRecoveryAcceptedSerializer, ) from sentry.api.serializers.rest_framework.base import CamelSnakeSerializer +from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_TOO_MANY_REQUESTS from sentry.auth import password_validation from sentry.auth.twofactor import reset_2fa_rate_limits from sentry.security.utils import capture_security_activity @@ -81,7 +82,7 @@ class AuthRecoveryEndpoint(Endpoint): @extend_schema( operation_id="Request account password recovery", request=AuthRecoveryRequestSerializer, - responses={202: AuthRecoveryAcceptedSerializer}, + responses={202: AuthRecoveryAcceptedSerializer, 429: RESPONSE_TOO_MANY_REQUESTS}, ) def post(self, request: Request) -> Response: if is_rate_limited(request, "request"): @@ -120,7 +121,7 @@ class AuthRecoveryConfirmEndpoint(Endpoint): @extend_schema( operation_id="Validate account password recovery token", parameters=[AuthRecoveryTokenRequestSerializer], - responses={200: AuthRecoveryTokenResponseSerializer}, + responses={200: AuthRecoveryTokenResponseSerializer, 429: RESPONSE_TOO_MANY_REQUESTS}, ) def get(self, request: Request) -> Response: if is_rate_limited(request, "validate"): @@ -148,7 +149,7 @@ def get(self, request: Request) -> Response: @extend_schema( operation_id="Complete account password recovery", request=AuthRecoveryConfirmRequestSerializer, - responses={204: None}, + responses={204: None, 400: RESPONSE_BAD_REQUEST, 429: RESPONSE_TOO_MANY_REQUESTS}, ) def post(self, request: Request) -> Response: if is_rate_limited(request, "confirm"): diff --git a/src/sentry/api/endpoints/auth_register.py b/src/sentry/api/endpoints/auth_register.py index bd6a00161b40..466b7d612c8e 100644 --- a/src/sentry/api/endpoints/auth_register.py +++ b/src/sentry/api/endpoints/auth_register.py @@ -25,6 +25,7 @@ RegistrationRequest, RegistrationValidator, ) +from sentry.apidocs.constants import RESPONSE_CONFLICT, RESPONSE_FORBIDDEN from sentry.organizations.services.organization import RpcOrganization, organization_service from sentry.ratelimits.config import RateLimitConfig from sentry.signals import user_signup @@ -141,7 +142,7 @@ class AuthRegisterEndpoint(Endpoint): @extend_schema( operation_id="Register a user account", request=RegistrationValidator, - responses={200: AuthSuccessSerializer}, + responses={200: AuthSuccessSerializer, 403: RESPONSE_FORBIDDEN, 409: RESPONSE_CONFLICT}, ) def post(self, request: Request) -> Response: if request.user.is_authenticated: diff --git a/src/sentry/api/endpoints/organization_agentic_onboarding.py b/src/sentry/api/endpoints/organization_agentic_onboarding.py index 37e70f4e6bea..135bd1995c27 100644 --- a/src/sentry/api/endpoints/organization_agentic_onboarding.py +++ b/src/sentry/api/endpoints/organization_agentic_onboarding.py @@ -22,6 +22,12 @@ AgenticOnboardingRunSerializer, ) from sentry.api.serializers.rest_framework import CamelSnakeSerializer +from sentry.apidocs.constants import ( + RESPONSE_BAD_REQUEST, + RESPONSE_CONFLICT, + RESPONSE_GONE, + RESPONSE_NOT_FOUND, +) from sentry.auth.principal import require_user from sentry.models.organization import Organization from sentry.onboarding.agentic_progress.model import ( @@ -240,7 +246,11 @@ class OrganizationAgenticOnboardingRunIndexEndpoint(OrganizationEndpoint): @extend_schema( request=AgenticOnboardingRunRequestSerializer, - responses={201: AgenticOnboardingRunSerializer}, + responses={ + 201: AgenticOnboardingRunSerializer, + 404: RESPONSE_NOT_FOUND, + 409: RESPONSE_CONFLICT, + }, ) def post(self, request: Request, organization: Organization) -> Response: serializer = AgenticOnboardingRunRequestSerializer(data=request.data) @@ -280,7 +290,13 @@ class OrganizationAgenticOnboardingStatusEndpoint(OrganizationEndpoint): @extend_schema( request=AgenticOnboardingStatusRequestSerializer, - responses={200: AgenticOnboardingRunSerializer}, + responses={ + 200: AgenticOnboardingRunSerializer, + 400: RESPONSE_BAD_REQUEST, + 404: RESPONSE_NOT_FOUND, + 409: RESPONSE_CONFLICT, + 410: RESPONSE_GONE, + }, ) def post(self, request: Request, organization: Organization) -> Response: serializer = AgenticOnboardingStatusRequestSerializer(data=request.data) diff --git a/src/sentry/api/endpoints/organization_agentic_onboarding_run_details.py b/src/sentry/api/endpoints/organization_agentic_onboarding_run_details.py index b26661a84a67..fade97069837 100644 --- a/src/sentry/api/endpoints/organization_agentic_onboarding_run_details.py +++ b/src/sentry/api/endpoints/organization_agentic_onboarding_run_details.py @@ -11,6 +11,7 @@ from sentry.api.serializers.models.agentic_onboarding import ( AgenticOnboardingRunSerializer, ) +from sentry.apidocs.constants import RESPONSE_CONFLICT, RESPONSE_NOT_FOUND from sentry.auth.principal import require_user from sentry.models.organization import Organization from sentry.onboarding.agentic_progress.model import OnboardingRunTerminal @@ -26,7 +27,7 @@ class OrganizationAgenticOnboardingRunDetailsEndpoint(OrganizationEndpoint): publish_status = {"GET": ApiPublishStatus.PRIVATE, "DELETE": ApiPublishStatus.PRIVATE} owner = ApiOwner.VALUE_DISCOVERY - @extend_schema(responses={200: AgenticOnboardingRunSerializer}) + @extend_schema(responses={200: AgenticOnboardingRunSerializer, 404: RESPONSE_NOT_FOUND}) def get(self, request: Request, organization: Organization, run_id: str) -> Response: user_id = require_user(request).id run = get_onboarding_progress_service().get( @@ -37,7 +38,13 @@ def get(self, request: Request, organization: Organization, run_id: str) -> Resp return Response(serialize(run, request.user, AgenticOnboardingRunSerializer())) - @extend_schema(responses={200: AgenticOnboardingRunSerializer}) + @extend_schema( + responses={ + 200: AgenticOnboardingRunSerializer, + 404: RESPONSE_NOT_FOUND, + 409: RESPONSE_CONFLICT, + } + ) def delete(self, request: Request, organization: Organization, run_id: str) -> Response: user_id = require_user(request).id try: diff --git a/src/sentry/api/endpoints/organization_events_facets.py b/src/sentry/api/endpoints/organization_events_facets.py index b11c88e86d57..f232dfc1241f 100644 --- a/src/sentry/api/endpoints/organization_events_facets.py +++ b/src/sentry/api/endpoints/organization_events_facets.py @@ -12,6 +12,7 @@ from sentry.api.bases import NoProjects, OrganizationEventsEndpointBase from sentry.api.paginator import GenericOffsetPaginator from sentry.api.utils import handle_query_errors, update_snuba_params_with_timestamp +from sentry.apidocs.constants import RESPONSE_NOT_FOUND from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.models.organization import Organization from sentry.search.utils import DEVICE_CLASS @@ -38,7 +39,8 @@ class OrganizationEventsFacetsEndpoint(OrganizationEventsEndpointBase): responses={ 200: inline_sentry_response_serializer( "OrganizationEventsFacetsResponse", list[_KeyTopValues] - ) + ), + 404: RESPONSE_NOT_FOUND, }, ) def get(self, request: Request, organization: Organization) -> Response: diff --git a/src/sentry/api/endpoints/organization_events_stats.py b/src/sentry/api/endpoints/organization_events_stats.py index 73edeb8b78da..44ee2340dc9b 100644 --- a/src/sentry/api/endpoints/organization_events_stats.py +++ b/src/sentry/api/endpoints/organization_events_stats.py @@ -19,6 +19,7 @@ transform_query_columns_for_error_upsampling, ) from sentry.api.serializers.snuba import StatsTimeSeriesResult +from sentry.apidocs.constants import RESPONSE_BAD_REQUEST from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.auth.principal import acting_user from sentry.constants import MAX_TOP_EVENTS @@ -123,7 +124,8 @@ def check_if_results_have_data(self, results: SnubaTSResult | dict[str, SnubaTSR 200: inline_sentry_response_serializer( "OrganizationEventsStatsResponse", StatsTimeSeriesResult | dict[str, StatsTimeSeriesResult], - ) + ), + 400: RESPONSE_BAD_REQUEST, }, ) def get(self, request: Request, organization: Organization) -> Response: diff --git a/src/sentry/api/endpoints/organization_profiling_profiles.py b/src/sentry/api/endpoints/organization_profiling_profiles.py index 551dd5be91c7..754645cab455 100644 --- a/src/sentry/api/endpoints/organization_profiling_profiles.py +++ b/src/sentry/api/endpoints/organization_profiling_profiles.py @@ -122,6 +122,7 @@ class OrganizationProfilingFlamegraphEndpoint(OrganizationProfilingBaseEndpoint) 200: inline_sentry_response_serializer( "OrganizationProfilingFlamegraphResponse", dict[str, Any] ), + 400: RESPONSE_BAD_REQUEST, 401: RESPONSE_UNAUTHORIZED, 403: RESPONSE_FORBIDDEN, 404: RESPONSE_NOT_FOUND, diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes.py b/src/sentry/api/endpoints/organization_trace_item_attributes.py index aa501997108a..575c0b2bdd16 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes.py @@ -773,6 +773,7 @@ class OrganizationTraceItemAttributesEndpoint(OrganizationTraceItemAttributesEnd 200: inline_sentry_response_serializer( "ListTraceItemAttributesResponse", list[TraceItemAttributeKey] ), + 400: RESPONSE_BAD_REQUEST, 401: RESPONSE_UNAUTHORIZED, 403: RESPONSE_FORBIDDEN, 404: RESPONSE_NOT_FOUND, diff --git a/src/sentry/api/endpoints/organization_trace_item_stats.py b/src/sentry/api/endpoints/organization_trace_item_stats.py index 3bfdefb56890..5d9781aefddf 100644 --- a/src/sentry/api/endpoints/organization_trace_item_stats.py +++ b/src/sentry/api/endpoints/organization_trace_item_stats.py @@ -27,7 +27,12 @@ ) from sentry.api.serializers.base import serialize from sentry.api.utils import handle_query_errors -from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED +from sentry.apidocs.constants import ( + RESPONSE_BAD_REQUEST, + RESPONSE_FORBIDDEN, + RESPONSE_NOT_FOUND, + RESPONSE_UNAUTHORIZED, +) from sentry.apidocs.examples.trace_item_stats_examples import TraceItemStatsExamples from sentry.apidocs.omissions import sentry_schema_serializer from sentry.apidocs.parameters import CursorQueryParam, GlobalParams, OrganizationParams @@ -206,6 +211,7 @@ class OrganizationTraceItemStatsEndpoint(OrganizationEventsEndpointBase): 200: inline_sentry_response_serializer( "TraceItemStatsResponse", TraceItemStatsResponse ), + 400: RESPONSE_BAD_REQUEST, 401: RESPONSE_UNAUTHORIZED, 403: RESPONSE_FORBIDDEN, 404: RESPONSE_NOT_FOUND, diff --git a/src/sentry/apidocs/constants.py b/src/sentry/apidocs/constants.py index 77fdca98577f..cc1a0a7d5ba1 100644 --- a/src/sentry/apidocs/constants.py +++ b/src/sentry/apidocs/constants.py @@ -30,5 +30,8 @@ # 409 RESPONSE_CONFLICT = OpenApiResponse(description="Conflict") +# 410 +RESPONSE_GONE = OpenApiResponse(description="Gone") + # 429 RESPONSE_TOO_MANY_REQUESTS = OpenApiResponse(description="Too Many Requests") diff --git a/src/sentry/core/endpoints/organization_member_details.py b/src/sentry/core/endpoints/organization_member_details.py index 519bb7144515..92b82fdcb14b 100644 --- a/src/sentry/core/endpoints/organization_member_details.py +++ b/src/sentry/core/endpoints/organization_member_details.py @@ -24,6 +24,7 @@ RESPONSE_FORBIDDEN, RESPONSE_NO_CONTENT, RESPONSE_NOT_FOUND, + RESPONSE_TOO_MANY_REQUESTS, RESPONSE_UNAUTHORIZED, ) from sentry.apidocs.examples.organization_examples import OrganizationExamples @@ -195,6 +196,7 @@ def get( 400: RESPONSE_BAD_REQUEST, 401: RESPONSE_UNAUTHORIZED, 403: RESPONSE_FORBIDDEN, + 429: RESPONSE_TOO_MANY_REQUESTS, }, examples=OrganizationExamples.UPDATE_ORG_MEMBER, ) @@ -489,6 +491,7 @@ def _handle_deletion_by_member( ], responses={ 204: RESPONSE_NO_CONTENT, + 400: RESPONSE_BAD_REQUEST, 401: RESPONSE_UNAUTHORIZED, 403: RESPONSE_FORBIDDEN, 404: RESPONSE_NOT_FOUND, diff --git a/src/sentry/core/endpoints/organization_member_index.py b/src/sentry/core/endpoints/organization_member_index.py index e657dfda1cb0..d3ad32ef5020 100644 --- a/src/sentry/core/endpoints/organization_member_index.py +++ b/src/sentry/core/endpoints/organization_member_index.py @@ -18,7 +18,13 @@ from sentry.api.serializers import serialize from sentry.api.serializers.models.organization_member import OrganizationMemberSerializer from sentry.api.serializers.models.organization_member.response import OrganizationMemberResponse -from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED +from sentry.apidocs.constants import ( + RESPONSE_BAD_REQUEST, + RESPONSE_FORBIDDEN, + RESPONSE_NOT_FOUND, + RESPONSE_TOO_MANY_REQUESTS, + RESPONSE_UNAUTHORIZED, +) from sentry.apidocs.examples.organization_member_examples import OrganizationMemberExamples from sentry.apidocs.parameters import CursorQueryParam, GlobalParams, OrganizationParams from sentry.apidocs.response_types import ( @@ -338,9 +344,11 @@ def get( request=OrganizationMemberRequestSerializer, responses={ 201: OrganizationMemberSerializer, + 400: RESPONSE_BAD_REQUEST, 401: RESPONSE_UNAUTHORIZED, 403: RESPONSE_FORBIDDEN, 404: RESPONSE_NOT_FOUND, + 429: RESPONSE_TOO_MANY_REQUESTS, }, examples=OrganizationMemberExamples.CREATE_ORG_MEMBER, ) diff --git a/src/sentry/core/endpoints/organization_projects.py b/src/sentry/core/endpoints/organization_projects.py index bc8ee6455af6..c85cf7e4adbe 100644 --- a/src/sentry/core/endpoints/organization_projects.py +++ b/src/sentry/core/endpoints/organization_projects.py @@ -149,6 +149,7 @@ class OrganizationProjectsEndpoint(OrganizationEndpoint): 200: inline_sentry_response_serializer( "OrganizationProjectResponseDict", list[OrganizationProjectResponse] ), + 400: RESPONSE_BAD_REQUEST, 401: RESPONSE_UNAUTHORIZED, 403: RESPONSE_FORBIDDEN, 404: RESPONSE_NOT_FOUND, diff --git a/src/sentry/core/endpoints/organization_teams.py b/src/sentry/core/endpoints/organization_teams.py index 20d4581a711d..a47ba9725b4f 100644 --- a/src/sentry/core/endpoints/organization_teams.py +++ b/src/sentry/core/endpoints/organization_teams.py @@ -14,7 +14,12 @@ from sentry.api.paginator import OffsetPaginator from sentry.api.serializers import serialize from sentry.api.serializers.models.team import TeamSerializer, TeamSerializerResponse -from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND +from sentry.apidocs.constants import ( + RESPONSE_BAD_REQUEST, + RESPONSE_CONFLICT, + RESPONSE_FORBIDDEN, + RESPONSE_NOT_FOUND, +) from sentry.apidocs.examples.team_examples import TeamExamples from sentry.apidocs.omissions import sentry_schema_serializer from sentry.apidocs.parameters import ( @@ -193,6 +198,7 @@ def should_add_creator_to_team(self, request: Request) -> bool: 400: RESPONSE_BAD_REQUEST, 403: RESPONSE_FORBIDDEN, 404: OpenApiResponse(description="A team with this slug already exists."), + 409: RESPONSE_CONFLICT, }, examples=TeamExamples.CREATE_TEAM, ) diff --git a/src/sentry/core/endpoints/project_details.py b/src/sentry/core/endpoints/project_details.py index 99e19c85fea9..f2976d14481e 100644 --- a/src/sentry/core/endpoints/project_details.py +++ b/src/sentry/core/endpoints/project_details.py @@ -25,7 +25,13 @@ from sentry.api.serializers.models.project import DetailedProjectResponse, DetailedProjectSerializer from sentry.api.serializers.rest_framework.list import EmptyListField from sentry.api.serializers.rest_framework.origin import OriginField -from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NO_CONTENT, RESPONSE_NOT_FOUND +from sentry.apidocs.constants import ( + RESPONSE_BAD_REQUEST, + RESPONSE_CONFLICT, + RESPONSE_FORBIDDEN, + RESPONSE_NO_CONTENT, + RESPONSE_NOT_FOUND, +) from sentry.apidocs.examples.project_examples import ProjectExamples from sentry.apidocs.omissions import sentry_schema_serializer from sentry.apidocs.parameters import GlobalParams @@ -665,6 +671,7 @@ def _get_unresolved_count(self, project: Project) -> int: request=None, responses={ 200: DetailedProjectSerializer, + 400: RESPONSE_BAD_REQUEST, 403: RESPONSE_FORBIDDEN, 404: RESPONSE_NOT_FOUND, }, @@ -715,8 +722,10 @@ def get( request=ProjectAdminSerializer, responses={ 200: DetailedProjectSerializer, + 400: RESPONSE_BAD_REQUEST, 403: RESPONSE_FORBIDDEN, 404: RESPONSE_NOT_FOUND, + 409: RESPONSE_CONFLICT, }, examples=ProjectExamples.DETAILED_PROJECT, ) diff --git a/src/sentry/core/endpoints/scim/teams.py b/src/sentry/core/endpoints/scim/teams.py index 14a4fc0b3ef4..8aad300b37f7 100644 --- a/src/sentry/core/endpoints/scim/teams.py +++ b/src/sentry/core/endpoints/scim/teams.py @@ -25,6 +25,7 @@ TeamSCIMSerializer, ) from sentry.apidocs.constants import ( + RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_SUCCESS, @@ -259,6 +260,7 @@ def on_results(results: Sequence[Team]) -> dict[str, object]: request=SCIMTeamRequestBodySerializer, responses={ 201: TeamSCIMSerializer, + 400: RESPONSE_BAD_REQUEST, 401: RESPONSE_UNAUTHORIZED, 403: RESPONSE_FORBIDDEN, 404: RESPONSE_NOT_FOUND, @@ -577,6 +579,7 @@ def _handle_replace_patch_op( request=SCIMTeamPatchRequestSerializer, responses={ 204: RESPONSE_SUCCESS, + 400: RESPONSE_BAD_REQUEST, 401: RESPONSE_UNAUTHORIZED, 403: RESPONSE_FORBIDDEN, 404: RESPONSE_NOT_FOUND, diff --git a/src/sentry/core/endpoints/team_details.py b/src/sentry/core/endpoints/team_details.py index fe7be73d33df..ed57ec89e280 100644 --- a/src/sentry/core/endpoints/team_details.py +++ b/src/sentry/core/endpoints/team_details.py @@ -17,6 +17,7 @@ from sentry.api.serializers.models.team import TeamSerializerResponse from sentry.api.serializers.rest_framework.base import CamelSnakeModelSerializer from sentry.apidocs.constants import ( + RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN, RESPONSE_NO_CONTENT, RESPONSE_NOT_FOUND, @@ -118,6 +119,7 @@ def get(self, request: Request, team: Team) -> Response[TeamSerializerResponse]: request=TeamDetailsSerializer, responses={ 200: TeamRequestSerializer, + 400: RESPONSE_BAD_REQUEST, 401: RESPONSE_UNAUTHORIZED, 403: RESPONSE_FORBIDDEN, 404: RESPONSE_NOT_FOUND, diff --git a/src/sentry/core/endpoints/team_projects.py b/src/sentry/core/endpoints/team_projects.py index e873e670189b..6ead694bfa55 100644 --- a/src/sentry/core/endpoints/team_projects.py +++ b/src/sentry/core/endpoints/team_projects.py @@ -207,6 +207,7 @@ class TeamProjectsEndpoint(TeamEndpoint): 200: inline_sentry_response_serializer( "ListTeamProjectResponse", list[OrganizationProjectResponse] ), + 400: RESPONSE_BAD_REQUEST, 403: RESPONSE_FORBIDDEN, 404: OpenApiResponse(description="Team not found."), }, diff --git a/src/sentry/dashboards/endpoints/organization_dashboards.py b/src/sentry/dashboards/endpoints/organization_dashboards.py index 97dea82eb6dc..c297ce6e22ca 100644 --- a/src/sentry/dashboards/endpoints/organization_dashboards.py +++ b/src/sentry/dashboards/endpoints/organization_dashboards.py @@ -31,6 +31,7 @@ RESPONSE_CONFLICT, RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, + RESPONSE_UNAUTHORIZED, ) from sentry.apidocs.examples.dashboard_examples import DashboardExamples from sentry.apidocs.parameters import ( @@ -443,6 +444,7 @@ class OrganizationDashboardsEndpoint(OrganizationEndpoint): "DashboardListResponse", list[DashboardListResponse] ), 400: RESPONSE_BAD_REQUEST, + 401: RESPONSE_UNAUTHORIZED, 403: RESPONSE_FORBIDDEN, 404: RESPONSE_NOT_FOUND, }, @@ -699,6 +701,7 @@ def handle_results(results: list[Dashboard]) -> list[DashboardListResponse]: responses={ 201: DashboardDetailsModelSerializer, 400: RESPONSE_BAD_REQUEST, + 401: RESPONSE_UNAUTHORIZED, 403: RESPONSE_FORBIDDEN, 404: RESPONSE_NOT_FOUND, 409: RESPONSE_CONFLICT, diff --git a/src/sentry/integrations/api/endpoints/organization_integrations_index.py b/src/sentry/integrations/api/endpoints/organization_integrations_index.py index 6d7429c6643c..70d43447d3e8 100644 --- a/src/sentry/integrations/api/endpoints/organization_integrations_index.py +++ b/src/sentry/integrations/api/endpoints/organization_integrations_index.py @@ -13,6 +13,7 @@ from sentry.api.bases.organization import OrganizationIntegrationsPermission from sentry.api.paginator import OffsetPaginator from sentry.api.serializers import serialize +from sentry.apidocs.constants import RESPONSE_BAD_REQUEST from sentry.apidocs.examples.integration_examples import IntegrationExamples from sentry.apidocs.parameters import CursorQueryParam, GlobalParams, IntegrationParams from sentry.apidocs.response_types import DetailResponse @@ -79,6 +80,7 @@ class OrganizationIntegrationsEndpoint(OrganizationIntegrationBaseEndpoint): 200: inline_sentry_response_serializer( "ListOrganizationIntegrationResponse", list[OrganizationIntegrationResponse] ), + 400: RESPONSE_BAD_REQUEST, }, examples=IntegrationExamples.LIST_INTEGRATIONS, ) diff --git a/src/sentry/issues/endpoints/group_notes.py b/src/sentry/issues/endpoints/group_notes.py index 735e640992a9..6abc57a13349 100644 --- a/src/sentry/issues/endpoints/group_notes.py +++ b/src/sentry/issues/endpoints/group_notes.py @@ -13,6 +13,7 @@ serialize_group_notes, ) from sentry.api.serializers.rest_framework.group_notes import NoteSerializer +from sentry.apidocs.constants import RESPONSE_BAD_REQUEST from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.auth.principal import acting_user from sentry.constants import CELL_API_DEPRECATION_DATE @@ -67,7 +68,8 @@ def get(self, request: Request, group: Group) -> Response: @extend_schema( request=NoteSerializer, responses={ - 201: inline_sentry_response_serializer("CreateGroupNote", GroupNoteSerializerResponse) + 201: inline_sentry_response_serializer("CreateGroupNote", GroupNoteSerializerResponse), + 400: RESPONSE_BAD_REQUEST, }, ) @deprecated( diff --git a/src/sentry/issues/endpoints/group_notes_details.py b/src/sentry/issues/endpoints/group_notes_details.py index 31202ad447f6..4d4c30c9c7d7 100644 --- a/src/sentry/issues/endpoints/group_notes_details.py +++ b/src/sentry/issues/endpoints/group_notes_details.py @@ -14,7 +14,7 @@ ) from sentry.api.serializers.rest_framework.group_notes import NoteSerializer from sentry.api.utils import to_valid_int_id -from sentry.apidocs.constants import RESPONSE_NO_CONTENT +from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_NO_CONTENT from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.auth.principal import is_owned_by_acting_user, require_user from sentry.constants import CELL_API_DEPRECATION_DATE @@ -80,7 +80,8 @@ def delete(self, request: Request, group: Group, note_id: str) -> Response: @extend_schema( request=NoteSerializer, responses={ - 200: inline_sentry_response_serializer("UpdateGroupNote", GroupNoteSerializerResponse) + 200: inline_sentry_response_serializer("UpdateGroupNote", GroupNoteSerializerResponse), + 400: RESPONSE_BAD_REQUEST, }, ) @deprecated( diff --git a/src/sentry/issues/endpoints/project_user_issue.py b/src/sentry/issues/endpoints/project_user_issue.py index b13ae2ae6294..9e10ad8243c2 100644 --- a/src/sentry/issues/endpoints/project_user_issue.py +++ b/src/sentry/issues/endpoints/project_user_issue.py @@ -12,6 +12,7 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint from sentry.api.bases.project import ProjectEndpoint, ProjectPermission +from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_NOT_FOUND from sentry.apidocs.parameters import GlobalParams from sentry.grouping.grouptype import ErrorGroupType from sentry.issues.grouptype import GroupType, WebVitalsGroup @@ -224,6 +225,8 @@ def has_feature(self, organization: Organization, request: Request) -> bool: request=ProjectUserIssueRequestSerializer, responses={ 200: ProjectUserIssueResponseSerializer, + 400: RESPONSE_BAD_REQUEST, + 404: RESPONSE_NOT_FOUND, }, ) def post(self, request: Request, project: Project) -> Response: diff --git a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py index a4df7f291f47..35600ed56b50 100644 --- a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py +++ b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py @@ -312,7 +312,12 @@ class OrganizationPreprodSnapshotEndpoint(OrganizationEndpoint): ), ], request=None, - responses={204: None, 403: RESPONSE_FORBIDDEN, 404: RESPONSE_NOT_FOUND}, + responses={ + 204: None, + 400: RESPONSE_BAD_REQUEST, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, ) def delete( self, request: Request, organization: Organization, snapshot_id: str diff --git a/src/sentry/releases/endpoints/organization_release_details.py b/src/sentry/releases/endpoints/organization_release_details.py index af156757a4bb..7fe237e5d538 100644 --- a/src/sentry/releases/endpoints/organization_release_details.py +++ b/src/sentry/releases/endpoints/organization_release_details.py @@ -32,6 +32,7 @@ ) from sentry.api.serializers.types import ReleaseSerializerResponse from sentry.apidocs.constants import ( + RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN, RESPONSE_NO_CONTENT, RESPONSE_NOT_FOUND, @@ -379,6 +380,7 @@ class OrganizationReleaseDetailsEndpoint( ], responses={ 200: inline_sentry_response_serializer("OrgReleaseResponse", ReleaseSerializerResponse), + 400: RESPONSE_BAD_REQUEST, 401: RESPONSE_UNAUTHORIZED, 403: RESPONSE_FORBIDDEN, 404: RESPONSE_NOT_FOUND, @@ -513,6 +515,7 @@ def get( request=OrganizationReleaseSerializer, responses={ 200: inline_sentry_response_serializer("OrgReleaseResponse", ReleaseSerializerResponse), + 400: RESPONSE_BAD_REQUEST, 401: RESPONSE_UNAUTHORIZED, 403: RESPONSE_FORBIDDEN, 404: RESPONSE_NOT_FOUND, @@ -644,6 +647,7 @@ def put( ], responses={ 204: RESPONSE_NO_CONTENT, + 400: RESPONSE_BAD_REQUEST, 401: RESPONSE_UNAUTHORIZED, 403: RESPONSE_FORBIDDEN, 404: RESPONSE_NOT_FOUND, diff --git a/src/sentry/replays/endpoints/organization_replay_count.py b/src/sentry/replays/endpoints/organization_replay_count.py index a502a54779f7..069b667be8d8 100644 --- a/src/sentry/replays/endpoints/organization_replay_count.py +++ b/src/sentry/replays/endpoints/organization_replay_count.py @@ -13,7 +13,7 @@ from sentry.api.bases import NoProjects from sentry.api.bases.organization_events import OrganizationEventsEndpointBase from sentry.api.utils import handle_query_errors -from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN +from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND from sentry.apidocs.examples.replay_examples import ReplayExamples from sentry.apidocs.omissions import sentry_schema_serializer from sentry.apidocs.parameters import ( @@ -105,6 +105,7 @@ class OrganizationReplayCountEndpoint(OrganizationEventsEndpointBase): 200: inline_sentry_response_serializer("ReplayCounts", dict[int, int]), 400: RESPONSE_BAD_REQUEST, 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, }, ) def get( diff --git a/src/sentry/seer/endpoints/organization_seer_workflow_configs.py b/src/sentry/seer/endpoints/organization_seer_workflow_configs.py index 1d8871bcbc32..105e2026b321 100644 --- a/src/sentry/seer/endpoints/organization_seer_workflow_configs.py +++ b/src/sentry/seer/endpoints/organization_seer_workflow_configs.py @@ -21,6 +21,7 @@ from sentry.api.paginator import OffsetPaginator from sentry.api.serializers.rest_framework.base import convert_dict_key_case, snake_to_camel_case from sentry.api.utils import to_valid_int_id, to_valid_int_id_list +from sentry.apidocs.constants import RESPONSE_BAD_REQUEST from sentry.apidocs.parameters import CursorQueryParam, GlobalParams from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.auth.principal import acting_user_id @@ -399,6 +400,7 @@ def get(self, request: Request, organization: Organization) -> Response: 201: inline_sentry_response_serializer( "SeerWorkflowConfig", SeerWorkflowConfigResponse ), + 400: RESPONSE_BAD_REQUEST, }, ) def post(self, request: Request, organization: Organization) -> Response: @@ -566,6 +568,7 @@ def get(self, request: Request, organization: Organization, config_id: str) -> R 200: inline_sentry_response_serializer( "SeerWorkflowConfig", SeerWorkflowConfigResponse ), + 400: RESPONSE_BAD_REQUEST, }, ) def put(self, request: Request, organization: Organization, config_id: str) -> Response: diff --git a/src/sentry/seer/endpoints/organization_seer_workflow_runs.py b/src/sentry/seer/endpoints/organization_seer_workflow_runs.py index 14119f0ce28c..c6df79873fea 100644 --- a/src/sentry/seer/endpoints/organization_seer_workflow_runs.py +++ b/src/sentry/seer/endpoints/organization_seer_workflow_runs.py @@ -19,6 +19,7 @@ from sentry.api.base import cell_silo_endpoint from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission from sentry.api.paginator import OffsetPaginator +from sentry.apidocs.constants import RESPONSE_BAD_REQUEST from sentry.apidocs.examples import seer_workflow_examples as SeerWorkflowExamples from sentry.apidocs.parameters import CursorQueryParam, GlobalParams from sentry.apidocs.utils import inline_sentry_response_serializer @@ -277,6 +278,7 @@ def get(self, request: Request, organization: Organization) -> Response: 202: inline_sentry_response_serializer( "SeerWorkflowRunCreate", WorkflowRunCreateResponse ), + 400: RESPONSE_BAD_REQUEST, }, ) def post(self, request: Request, organization: Organization) -> Response: diff --git a/src/sentry/workflow_engine/endpoints/organization_detector_anomaly_data.py b/src/sentry/workflow_engine/endpoints/organization_detector_anomaly_data.py index d6ec97636469..e6918c250ada 100644 --- a/src/sentry/workflow_engine/endpoints/organization_detector_anomaly_data.py +++ b/src/sentry/workflow_engine/endpoints/organization_detector_anomaly_data.py @@ -12,7 +12,12 @@ from sentry.api.bases.organization import OrganizationEndpoint from sentry.api.exceptions import ResourceDoesNotExist from sentry.api.utils import to_valid_int_id -from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED +from sentry.apidocs.constants import ( + RESPONSE_BAD_REQUEST, + RESPONSE_FORBIDDEN, + RESPONSE_NOT_FOUND, + RESPONSE_UNAUTHORIZED, +) from sentry.apidocs.parameters import DetectorParams, GlobalParams from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.incidents.utils.types import DATA_SOURCE_SNUBA_QUERY_SUBSCRIPTION @@ -77,6 +82,7 @@ def _get_subscription_from_detector( 200: inline_sentry_response_serializer( "AnomalyThresholdDataResponse", AnomalyThresholdDataResponse ), + 400: RESPONSE_BAD_REQUEST, 401: RESPONSE_UNAUTHORIZED, 403: RESPONSE_FORBIDDEN, 404: RESPONSE_NOT_FOUND, diff --git a/tests/tools/test_flake8_plugin.py b/tests/tools/test_flake8_plugin.py index b52e23f3187f..97c0a888569c 100644 --- a/tests/tools/test_flake8_plugin.py +++ b/tests/tools/test_flake8_plugin.py @@ -22,6 +22,7 @@ S027_msg, S028_msg, S030_allowlist, + S034_msg, SentryCheck, _s015_msg, get_api_violations, @@ -980,6 +981,173 @@ def get(self) -> Response[BResponse]: ... ) +# --- S034: a 4xx returned inline must be a key in responses= --- + + +def test_S034_undocumented_status_constant_fires() -> None: + assert _resp("""\ +class E: + @extend_schema(responses={200: AResponse}) + def post(self) -> Response[AResponse]: + return Response({"detail": "x"}, status=status.HTTP_409_CONFLICT) +""") == ["4:S034"] + + +def test_S034_undocumented_literal_status_fires() -> None: + assert _resp("""\ +class E: + @extend_schema(responses={200: AResponse}) + def post(self) -> Response[AResponse]: + return Response({"detail": "x"}, 402) +""") == ["4:S034"] + + +def test_S034_bare_status_name_fires() -> None: + assert _resp("""\ +class E: + @extend_schema(responses={200: AResponse}) + def post(self) -> Response[AResponse]: + return Response(status=HTTP_404_NOT_FOUND) +""") == ["4:S034"] + + +def test_S034_documented_status_passes() -> None: + assert ( + _resp("""\ +class E: + @extend_schema(responses={200: AResponse, 409: RESPONSE_CONFLICT}) + def post(self) -> Response[AResponse]: + if self.x: + return Response({"detail": "x"}, status=status.HTTP_409_CONFLICT) + return Response({"a": "a"}, status=200) +""") + == [] + ) + + +def test_S034_tuple_and_string_keys_count_as_documented() -> None: + assert ( + _resp("""\ +class E: + @extend_schema(responses={(400, "application/json"): RESPONSE_BAD_REQUEST, "404": X}) + def post(self) -> Response[AResponse]: + if self.x: + return Response(status=400) + return Response(status=404) +""") + == [] + ) + + +def test_S034_non_4xx_statuses_skipped() -> None: + assert ( + _resp("""\ +class E: + @extend_schema(responses={200: AResponse}) + def post(self) -> Response[AResponse]: + if self.x: + return Response(status=status.HTTP_202_ACCEPTED) + return Response(status=500) +""") + == [] + ) + + +def test_S034_method_without_responses_skipped() -> None: + assert ( + _resp("""\ +class E: + @extend_schema(operation_id="x") + def post(self) -> Response[AResponse]: + return Response(status=409) +""") + == [] + ) + + +def test_S034_unreadable_responses_skipped() -> None: + assert ( + _resp("""\ +class E: + @extend_schema(responses=SHARED_RESPONSES) + def post(self) -> Response[AResponse]: + return Response(status=409) + + @extend_schema(responses={200: AResponse, **SHARED_ERRORS}) + def put(self) -> Response[AResponse]: + return Response(status=409) + + @extend_schema(responses={OK: AResponse}) + def delete(self) -> Response[AResponse]: + return Response(status=409) +""") + == [] + ) + + +def test_S034_helpers_variables_and_raises_not_followed() -> None: + assert ( + _resp("""\ +class E: + @extend_schema(responses={200: AResponse}) + def post(self) -> Response[AResponse]: + if self.a: + return self.helper() + if self.b: + raise Conflict() + response = Response(status=409) + return response +""") + == [] + ) + + +def test_S034_nested_function_return_not_attributed() -> None: + assert ( + _resp("""\ +class E: + @extend_schema(responses={200: AResponse}) + def post(self) -> Response[AResponse]: + def inner(): + return Response(status=409) + + return inner() +""") + == [] + ) + + +def test_S034_async_method_and_dotted_response_fire() -> None: + assert _resp("""\ +class E: + @extend_schema(responses={200: AResponse}) + async def post(self) -> Response[AResponse]: + return response.Response(status=status.HTTP_402_PAYMENT_REQUIRED) +""") == ["4:S034"] + + +def test_S034_module_level_function_skipped() -> None: + assert ( + _resp("""\ +@extend_schema(responses={200: AResponse}) +def post(request) -> Response[AResponse]: + return Response(status=409) +""") + == [] + ) + + +def test_S034_message_names_the_status() -> None: + tree = ast.parse("""\ +class E: + @extend_schema(responses={200: AResponse}) + def post(self) -> Response[AResponse]: + return Response(status=409) +""") + ((_line, _col, msg, _cls),) = SentryCheck(tree=tree, filename="t.py").run() + assert msg == S034_msg.format(409) + + # --- S022: published methods must declare a response shape --- diff --git a/tools/api_guidelines/rules.py b/tools/api_guidelines/rules.py index e7fee56af587..613e32a776ac 100644 --- a/tools/api_guidelines/rules.py +++ b/tools/api_guidelines/rules.py @@ -6,7 +6,7 @@ class ApiRule(StrEnum): Rule codes tracked by the API guidelines tooling Each rule is defined, with its message, in tools/flake8_plugin.py. S021-S023 - are listed for reference: they're always enforced and have no baseline. Only + and S034 are listed for reference: they're always enforced and have no baseline. Only S025-S028 are baselined. Later stages add their codes here """ @@ -17,3 +17,4 @@ class ApiRule(StrEnum): S026 = "S026" S027 = "S027" S028 = "S028" + S034 = "S034" diff --git a/tools/flake8_plugin.py b/tools/flake8_plugin.py index 4e7bc13369f2..a37b92bbd1fb 100644 --- a/tools/flake8_plugin.py +++ b/tools/flake8_plugin.py @@ -237,6 +237,11 @@ "@extend_schema(request=...), so the schema does not document what this " "endpoint accepts. Add it as request=." ) +S034_msg = ( + f"{ApiRule.S034} This method returns a {{0}} Response, but {{0}} is not a key in " + "@extend_schema(responses=...), so the schema does not document it and generated " + "clients cannot type it. Add it to responses=." +) def _s015_msg() -> str: @@ -663,6 +668,74 @@ def _decorator_response_Ts(decorator: ast.expr) -> list[ast.expr]: return out +def _documented_statuses(decorators: list[ast.expr]) -> set[int] | None: + """Status codes keyed in every `@extend_schema(responses={...})` in `decorators`. + + `None` when no decorator declares `responses=`, or when a key can't be read + statically (a name, a `**` spread, or `responses=` that isn't a dict literal): + S034 then has nothing reliable to compare against. A `(status, media_type)` + tuple key counts as its status.""" + statuses: set[int] | None = None + for value in extend_schema_kwarg(decorators, "responses"): + if not isinstance(value, ast.Dict): + return None + statuses = statuses or set() + for key in value.keys: + if isinstance(key, ast.Tuple) and key.elts: + key = key.elts[0] + if not isinstance(key, ast.Constant): + return None + if isinstance(key.value, int): + statuses.add(key.value) + elif isinstance(key.value, str) and key.value.isdigit(): + statuses.add(int(key.value)) + else: + return None + return statuses + + +def _status_code(node: ast.expr) -> int | None: + """`404` for `404`, `status.HTTP_404_NOT_FOUND`, or a bare `HTTP_404_NOT_FOUND`.""" + if isinstance(node, ast.Constant) and isinstance(node.value, int): + return node.value + if isinstance(node, ast.Attribute): + name = node.attr + elif isinstance(node, ast.Name): + name = node.id + else: + return None + parts = name.split("_") + if len(parts) >= 2 and parts[0] == "HTTP" and parts[1].isdigit(): + return int(parts[1]) + return None + + +def _inline_response_statuses( + node: ast.FunctionDef | ast.AsyncFunctionDef, +) -> Iterator[tuple[ast.Call, int]]: + """Each `return Response(..., status=N)` in `node`'s own body, with its status. + + Nested functions, lambdas, and classes are not entered: their returns don't + leave this method.""" + pending: list[ast.AST] = list(node.body) + while pending: + child = pending.pop() + if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)): + continue + pending.extend(ast.iter_child_nodes(child)) + if not isinstance(child, ast.Return) or not isinstance(child.value, ast.Call): + continue + call = child.value + if _name_of(call.func).rsplit(".", 1)[-1] != "Response": + continue + status_expr = next((kw.value for kw in call.keywords if kw.arg == "status"), None) + if status_expr is None and len(call.args) >= 2: + status_expr = call.args[1] + code = _status_code(status_expr) if status_expr is not None else None + if code is not None: + yield call, code + + _UNION_NAMES = frozenset({"Union", "Optional"}) @@ -1232,6 +1305,7 @@ def visit_FunctionDef(self, node: ast.FunctionDef) -> None: if len(self._class_stack) == 1 and self._function_depth == 0: self._check_S021(node) self._check_S022(node) + self._check_S034(node) entered = self._enter_input(node) self._function_depth += 1 self._function_stack.append(node.name) @@ -1247,6 +1321,7 @@ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: if len(self._class_stack) == 1 and self._function_depth == 0: self._check_S021(node) self._check_S022(node) + self._check_S034(node) entered = self._enter_input(node) self._function_depth += 1 self._function_stack.append(node.name) @@ -1354,6 +1429,21 @@ def _check_S022(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: elif _annotation_has_bare_response(node.returns): self.errors.append((node.lineno, node.col_offset, S022_bare_msg)) + def _check_S034(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: + """A 4xx that the method returns inline must be a key in `responses=`. + + Only `return Response(..., status=...)` written in the method itself, with a + literal or `status.HTTP_4xx_*` status, is checked. A 4xx that comes from a + helper's return value, a response built into a variable first, or a raised + exception (DRF renders `APIException`s) is not seen, so passing this rule + does not mean every 4xx is documented.""" + documented = _documented_statuses(node.decorator_list) + if documented is None: + return + for call, code in _inline_response_statuses(node): + if 400 <= code < 500 and code not in documented: + self.errors.append((call.lineno, call.col_offset, S034_msg.format(code))) + def _check_S023(self, node: ast.ClassDef) -> None: for dec in node.decorator_list: if not isinstance(dec, ast.Call):