From 87915fa41a51c69a563c50b10cb329e8d0f837e6 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 5 Jan 2023 13:33:19 -0800 Subject: [PATCH] feat: Add OpenAPI docs for monitor endpoints --- .../api/endpoints/monitor_checkin_details.py | 68 +++++++++++++---- src/sentry/api/endpoints/monitor_checkins.py | 75 ++++++++++++++----- src/sentry/api/endpoints/monitor_details.py | 54 +++++++++++++ src/sentry/api/serializers/models/monitor.py | 19 +++++ .../api/serializers/models/monitorcheckin.py | 11 +++ .../api/serializers/rest_framework/project.py | 2 + src/sentry/api/validators/monitor.py | 16 +++- src/sentry/apidocs/build.py | 10 +++ src/sentry/apidocs/constants.py | 11 +++ src/sentry/apidocs/hooks.py | 11 ++- src/sentry/apidocs/parameters.py | 17 +++++ src/sentry/apidocs/public_exclusion_list.py | 6 -- 12 files changed, 261 insertions(+), 39 deletions(-) diff --git a/src/sentry/api/endpoints/monitor_checkin_details.py b/src/sentry/api/endpoints/monitor_checkin_details.py index 2072b92784c445..6fd560660693dd 100644 --- a/src/sentry/api/endpoints/monitor_checkin_details.py +++ b/src/sentry/api/endpoints/monitor_checkin_details.py @@ -1,6 +1,6 @@ from django.db import transaction from django.utils import timezone -from rest_framework import serializers +from drf_spectacular.utils import extend_schema from rest_framework.request import Request from rest_framework.response import Response @@ -8,8 +8,18 @@ from sentry.api.base import Endpoint, region_silo_endpoint from sentry.api.bases.monitor import MonitorEndpoint, ProjectMonitorPermission from sentry.api.exceptions import ResourceDoesNotExist -from sentry.api.fields.empty_integer import EmptyIntegerField from sentry.api.serializers import serialize +from sentry.api.serializers.models.monitorcheckin import MonitorCheckInSerializerResponse +from sentry.api.validators import MonitorCheckInValidator +from sentry.apidocs.constants import ( + RESPONSE_ALREADY_REPORTED, + RESPONSE_BAD_REQUEST, + RESPONSE_FORBIDDEN, + RESPONSE_NOTFOUND, + RESPONSE_UNAUTHORIZED, +) +from sentry.apidocs.parameters import GLOBAL_PARAMS, MONITOR_PARAMS +from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.models import ( CheckInStatus, Monitor, @@ -22,21 +32,12 @@ from sentry.utils.sdk import bind_organization_context, configure_scope -class CheckInSerializer(serializers.Serializer): - status = serializers.ChoiceField( - choices=( - ("ok", CheckInStatus.OK), - ("error", CheckInStatus.ERROR), - ("in_progress", CheckInStatus.IN_PROGRESS), - ) - ) - duration = EmptyIntegerField(required=False, allow_null=True) - - @region_silo_endpoint +@extend_schema(tags=["Crons"]) class MonitorCheckInDetailsEndpoint(Endpoint): authentication_classes = MonitorEndpoint.authentication_classes + (DSNAuthentication,) permission_classes = (ProjectMonitorPermission,) + public = {"GET", "PUT"} # TODO(dcramer): this code needs shared with other endpoints as its security focused # TODO(dcramer): this doesnt handle is_global roles @@ -82,6 +83,23 @@ def convert_args(self, request: Request, monitor_id, checkin_id, *args, **kwargs kwargs.update({"checkin": checkin, "monitor": monitor, "project": project}) return (args, kwargs) + @extend_schema( + operation_id="Retrieve a check-in", + parameters=[ + GLOBAL_PARAMS.ORG_SLUG, + MONITOR_PARAMS.MONITOR_ID, + MONITOR_PARAMS.CHECKIN_ID, + ], + request=None, + responses={ + 201: inline_sentry_response_serializer( + "MonitorCheckIn", MonitorCheckInSerializerResponse + ), + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOTFOUND, + }, + ) def get(self, request: Request, project, monitor, checkin) -> Response: """ Retrieve a check-in @@ -100,6 +118,28 @@ def get(self, request: Request, project, monitor, checkin) -> Response: return self.respond(serialize(checkin, request.user)) + @extend_schema( + operation_id="Update a check-in", + parameters=[ + GLOBAL_PARAMS.ORG_SLUG, + MONITOR_PARAMS.MONITOR_ID, + MONITOR_PARAMS.CHECKIN_ID, + ], + request=MonitorCheckInValidator, + responses={ + 200: inline_sentry_response_serializer( + "MonitorCheckIn2", MonitorCheckInSerializerResponse + ), + 208: RESPONSE_ALREADY_REPORTED, + 400: RESPONSE_BAD_REQUEST, + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOTFOUND, + }, + examples=[ + # OpenApiExample() + ], + ) def put(self, request: Request, project, monitor, checkin) -> Response: """ Update a check-in @@ -115,7 +155,7 @@ def put(self, request: Request, project, monitor, checkin) -> Response: if checkin.status in CheckInStatus.FINISHED_VALUES: return self.respond(status=400) - serializer = CheckInSerializer( + serializer = MonitorCheckInValidator( data=request.data, partial=True, context={"project": project, "request": request} ) if not serializer.is_valid(): diff --git a/src/sentry/api/endpoints/monitor_checkins.py b/src/sentry/api/endpoints/monitor_checkins.py index 823b4edf38201f..59926d3ff76429 100644 --- a/src/sentry/api/endpoints/monitor_checkins.py +++ b/src/sentry/api/endpoints/monitor_checkins.py @@ -1,40 +1,58 @@ from __future__ import annotations +from typing import List + from django.db import transaction -from rest_framework import serializers +from drf_spectacular.utils import extend_schema from rest_framework.request import Request from rest_framework.response import Response from sentry.api.authentication import DSNAuthentication from sentry.api.base import region_silo_endpoint from sentry.api.bases.monitor import MonitorEndpoint -from sentry.api.fields.empty_integer import EmptyIntegerField from sentry.api.paginator import OffsetPaginator from sentry.api.serializers import serialize +from sentry.api.serializers.models.monitorcheckin import MonitorCheckInSerializerResponse +from sentry.api.validators import MonitorCheckInValidator +from sentry.apidocs.constants import ( + RESPONSE_BAD_REQUEST, + RESPONSE_FORBIDDEN, + RESPONSE_NOTFOUND, + RESPONSE_UNAUTHORIZED, +) +from sentry.apidocs.parameters import GLOBAL_PARAMS, MONITOR_PARAMS +from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.models import CheckInStatus, Monitor, MonitorCheckIn, MonitorStatus, ProjectKey -class CheckInSerializer(serializers.Serializer): - status = serializers.ChoiceField( - choices=( - ("ok", CheckInStatus.OK), - ("error", CheckInStatus.ERROR), - ("in_progress", CheckInStatus.IN_PROGRESS), - ) - ) - duration = EmptyIntegerField(required=False, allow_null=True) - - @region_silo_endpoint +@extend_schema(tags=["Crons"]) class MonitorCheckInsEndpoint(MonitorEndpoint): authentication_classes = MonitorEndpoint.authentication_classes + (DSNAuthentication,) - + public = {"GET", "POST"} + + @extend_schema( + operation_id="Retrieve check-ins for a monitor", + parameters=[ + GLOBAL_PARAMS.ORG_SLUG, + MONITOR_PARAMS.MONITOR_ID, + MONITOR_PARAMS.CHECKIN_ID, + ], + responses={ + 200: inline_sentry_response_serializer( + "CheckInList", List[MonitorCheckInSerializerResponse] + ), + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOTFOUND, + }, + ) def get( self, request: Request, project, monitor, organization_slug: str | None = None ) -> Response: """ - Retrieve check-ins for an monitor - ````````````````````````````````` + Retrieve check-ins for a monitor + ```````````````````````````````` :pparam string monitor_id: the id of the monitor. :auth: required @@ -57,6 +75,27 @@ def get( paginator_cls=OffsetPaginator, ) + @extend_schema( + operation_id="Create a new check-in", + parameters=[ + GLOBAL_PARAMS.ORG_SLUG, + MONITOR_PARAMS.MONITOR_ID, + MONITOR_PARAMS.CHECKIN_ID, + ], + request=MonitorCheckInValidator, + responses={ + 200: inline_sentry_response_serializer( + "MonitorCheckIn", MonitorCheckInSerializerResponse + ), + 201: inline_sentry_response_serializer( + "MonitorCheckIn", MonitorCheckInSerializerResponse + ), + 400: RESPONSE_BAD_REQUEST, + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOTFOUND, + }, + ) def post( self, request: Request, project, monitor, organization_slug: str | None = None ) -> Response: @@ -66,6 +105,8 @@ def post( :pparam string monitor_id: the id of the monitor. :auth: required + + Note: If a DSN is utilized for authentication, the response will be limited in details. """ if organization_slug: if project.organization.slug != organization_slug: @@ -74,7 +115,7 @@ def post( if monitor.status in [MonitorStatus.PENDING_DELETION, MonitorStatus.DELETION_IN_PROGRESS]: return self.respond(status=404) - serializer = CheckInSerializer( + serializer = MonitorCheckInValidator( data=request.data, context={"project": project, "request": request} ) if not serializer.is_valid(): diff --git a/src/sentry/api/endpoints/monitor_details.py b/src/sentry/api/endpoints/monitor_details.py index 3feb8e8e277f54..3f8bb077404b29 100644 --- a/src/sentry/api/endpoints/monitor_details.py +++ b/src/sentry/api/endpoints/monitor_details.py @@ -1,6 +1,7 @@ from __future__ import annotations from django.db import transaction +from drf_spectacular.utils import extend_schema from rest_framework.request import Request from rest_framework.response import Response @@ -8,12 +9,37 @@ from sentry.api.base import region_silo_endpoint from sentry.api.bases.monitor import MonitorEndpoint from sentry.api.serializers import serialize +from sentry.api.serializers.models.monitor import MonitorSerializerResponse from sentry.api.validators import MonitorValidator +from sentry.apidocs.constants import ( + RESPONSE_ACCEPTED, + RESPONSE_FORBIDDEN, + RESPONSE_NOTFOUND, + RESPONSE_UNAUTHORIZED, +) +from sentry.apidocs.parameters import GLOBAL_PARAMS, MONITOR_PARAMS +from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.models import Monitor, MonitorStatus, ScheduledDeletion @region_silo_endpoint +@extend_schema(tags=["Crons"]) class MonitorDetailsEndpoint(MonitorEndpoint): + public = {"GET", "PUT", "DELETE"} + + @extend_schema( + operation_id="Retrieve a monitor", + parameters=[ + GLOBAL_PARAMS.ORG_SLUG, + MONITOR_PARAMS.MONITOR_ID, + ], + responses={ + 200: inline_sentry_response_serializer("Monitor", MonitorSerializerResponse), + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOTFOUND, + }, + ) def get( self, request: Request, project, monitor, organization_slug: str | None = None ) -> Response: @@ -30,6 +56,20 @@ def get( return self.respond(serialize(monitor, request.user)) + @extend_schema( + operation_id="Update a monitor", + parameters=[ + GLOBAL_PARAMS.ORG_SLUG, + MONITOR_PARAMS.MONITOR_ID, + ], + request=MonitorValidator, + responses={ + 200: inline_sentry_response_serializer("Monitor", MonitorSerializerResponse), + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOTFOUND, + }, + ) def put( self, request: Request, project, monitor, organization_slug: str | None = None ) -> Response: @@ -87,6 +127,20 @@ def put( return self.respond(serialize(monitor, request.user)) + @extend_schema( + operation_id="Delete a monitor", + parameters=[ + GLOBAL_PARAMS.ORG_SLUG, + MONITOR_PARAMS.MONITOR_ID, + ], + request=MonitorValidator, + responses={ + 202: RESPONSE_ACCEPTED, + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOTFOUND, + }, + ) def delete( self, request: Request, project, monitor, organization_slug: str | None = None ) -> Response: diff --git a/src/sentry/api/serializers/models/monitor.py b/src/sentry/api/serializers/models/monitor.py index 796cdc14decf28..8adf924325b2e3 100644 --- a/src/sentry/api/serializers/models/monitor.py +++ b/src/sentry/api/serializers/models/monitor.py @@ -1,6 +1,13 @@ +from datetime import datetime +from typing import Any + +from typing_extensions import TypedDict + from sentry.api.serializers import Serializer, register, serialize from sentry.models import Monitor, Project +from .project import ProjectSerializerResponse + @register(Monitor) class MonitorSerializer(Serializer): @@ -33,3 +40,15 @@ def serialize(self, obj, attrs, user): "dateCreated": obj.date_added, "project": attrs["project"], } + + +class MonitorSerializerResponse(TypedDict): + id: str + name: str + status: str + type: str + config: Any + dateCreated: datetime + lastCheckIn: datetime + nextCheckIn: datetime + project: ProjectSerializerResponse diff --git a/src/sentry/api/serializers/models/monitorcheckin.py b/src/sentry/api/serializers/models/monitorcheckin.py index bb8f6d03f90c4e..0b9ed9034e3b9e 100644 --- a/src/sentry/api/serializers/models/monitorcheckin.py +++ b/src/sentry/api/serializers/models/monitorcheckin.py @@ -1,3 +1,7 @@ +from datetime import datetime + +from typing_extensions import TypedDict + from sentry.api.serializers import Serializer, register from sentry.models import MonitorCheckIn @@ -11,3 +15,10 @@ def serialize(self, obj, attrs, user): "duration": obj.duration, "dateCreated": obj.date_added, } + + +class MonitorCheckInSerializerResponse(TypedDict): + id: str + status: str + duration: int + dateCreated: datetime diff --git a/src/sentry/api/serializers/rest_framework/project.py b/src/sentry/api/serializers/rest_framework/project.py index 2beeaa5f0c1b7b..12ac930d67e251 100644 --- a/src/sentry/api/serializers/rest_framework/project.py +++ b/src/sentry/api/serializers/rest_framework/project.py @@ -1,3 +1,4 @@ +from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from sentry.models import Project @@ -5,6 +6,7 @@ ValidationError = serializers.ValidationError +@extend_schema_field(str) class ProjectField(serializers.Field): def __init__(self, scope="project:write"): self.scope = scope diff --git a/src/sentry/api/validators/monitor.py b/src/sentry/api/validators/monitor.py index 7e2347b86c659b..93875c1a8d758f 100644 --- a/src/sentry/api/validators/monitor.py +++ b/src/sentry/api/validators/monitor.py @@ -1,10 +1,12 @@ from croniter import croniter from django.core.exceptions import ValidationError +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from sentry.api.fields.empty_integer import EmptyIntegerField from sentry.api.serializers.rest_framework.project import ProjectField -from sentry.models import MonitorStatus, MonitorType, ScheduleType +from sentry.models import CheckInStatus, MonitorStatus, MonitorType, ScheduleType SCHEDULE_TYPES = { "crontab": ScheduleType.CRONTAB, @@ -31,6 +33,7 @@ } +@extend_schema_field(OpenApiTypes.ANY) class ObjectField(serializers.Field): def to_internal_value(self, data): return data @@ -129,3 +132,14 @@ def update(self, instance, validated_data): def create(self, validated_data): return validated_data + + +class MonitorCheckInValidator(serializers.Serializer): + status = serializers.ChoiceField( + choices=( + ("ok", CheckInStatus.OK), + ("error", CheckInStatus.ERROR), + ("in_progress", CheckInStatus.IN_PROGRESS), + ) + ) + duration = EmptyIntegerField(required=False, allow_null=True) diff --git a/src/sentry/apidocs/build.py b/src/sentry/apidocs/build.py index debe53a946c559..6a2f5f51c15641 100644 --- a/src/sentry/apidocs/build.py +++ b/src/sentry/apidocs/build.py @@ -91,4 +91,14 @@ def get_old_json_paths(filename: str) -> json.JSONData: "url": "https://github.com/getsentry/sentry-docs/issues/new/?title=API%20Documentation%20Error:%20/api/integration-platform/&template=api_error_template.md", }, }, + { + "name": "Crons", + "x-sidebar-name": "Crons (Beta)", + "description": "Endpoints for Crons", + "x-display-description": True, + "externalDocs": { + "description": "Found an error? Let us know.", + "url": "https://github.com/getsentry/sentry-docs/issues/new/?title=API%20Documentation%20Error:%20/api/integration-platform/&template=api_error_template.md", + }, + }, ] diff --git a/src/sentry/apidocs/constants.py b/src/sentry/apidocs/constants.py index 1909ca46a662d7..6b0f4f748d4f47 100644 --- a/src/sentry/apidocs/constants.py +++ b/src/sentry/apidocs/constants.py @@ -2,10 +2,21 @@ RESPONSE_UNAUTHORIZED = OpenApiResponse(description="Unauthorized") +# 400 +RESPONSE_BAD_REQUEST = OpenApiResponse(description="Bad Request") + RESPONSE_FORBIDDEN = OpenApiResponse(description="Forbidden") RESPONSE_NOTFOUND = OpenApiResponse(description="Not Found") +# 200 RESPONSE_SUCCESS = OpenApiResponse(description="Success") +# 201 - Created RESPONSE_NO_CONTENT = OpenApiResponse(description="No Content") + +# 202 - Accepted (not yet acted on fully) +RESPONSE_ACCEPTED = OpenApiResponse(description="Accepted") + +# 208 +RESPONSE_ALREADY_REPORTED = OpenApiResponse(description="Already Reported") diff --git a/src/sentry/apidocs/hooks.py b/src/sentry/apidocs/hooks.py index b12a2b05cb4934..5e646ba3ce1714 100644 --- a/src/sentry/apidocs/hooks.py +++ b/src/sentry/apidocs/hooks.py @@ -19,6 +19,11 @@ class EndpointRegistryType(TypedDict): _DEFINED_TAG_SET = {t["name"] for t in OPENAPI_TAGS} +# path prefixes to exclude +# this is useful if we're duplicating an endpoint for legacy purposes +# but do not want to document it +EXCLUSION_PATH_PREFIXES = ["/api/0/monitors/"] + def custom_preprocessing_hook(endpoints: Any) -> Any: # TODO: organize method, rename from sentry.apidocs.public_exclusion_list import ( @@ -37,7 +42,11 @@ def custom_preprocessing_hook(endpoints: Any) -> Any: # TODO: organize method, "both `public` and `private` cannot be defined at the same time, " "please remove one of the attributes." ) - if callback.view_class.public: + + if any(path.startswith(p) for p in EXCLUSION_PATH_PREFIXES): + pass + + elif callback.view_class.public: # endpoints that are documented via tooling if method in callback.view_class.public: # only pass declared public methods of the endpoint diff --git a/src/sentry/apidocs/parameters.py b/src/sentry/apidocs/parameters.py index e12cbc9662ffac..92ce80a92ea099 100644 --- a/src/sentry/apidocs/parameters.py +++ b/src/sentry/apidocs/parameters.py @@ -141,3 +141,20 @@ class CURSOR_QUERY_PARAM(serializers.Serializer): # type: ignore help_text="A pointer to the last object fetched and its sort order; used to retrieve the next or previous results.", required=False, ) + + +class MONITOR_PARAMS: + MONITOR_ID = OpenApiParameter( + name="monitor_id", + location="path", + required=True, + type=OpenApiTypes.UUID, + description="The id of the monitor", + ) + CHECKIN_ID = OpenApiParameter( + name="checkin_id", + location="path", + required=True, + type=OpenApiTypes.UUID, + description="The id of the check-in", + ) diff --git a/src/sentry/apidocs/public_exclusion_list.py b/src/sentry/apidocs/public_exclusion_list.py index 84bc5b6768e14d..16a3e8e210fe73 100644 --- a/src/sentry/apidocs/public_exclusion_list.py +++ b/src/sentry/apidocs/public_exclusion_list.py @@ -126,9 +126,6 @@ InternalStatsEndpoint, InternalWarningsEndpoint, ) -from sentry.api.endpoints.monitor_checkin_details import MonitorCheckInDetailsEndpoint -from sentry.api.endpoints.monitor_checkins import MonitorCheckInsEndpoint -from sentry.api.endpoints.monitor_details import MonitorDetailsEndpoint from sentry.api.endpoints.monitor_stats import MonitorStatsEndpoint from sentry.api.endpoints.organization_access_request_details import ( OrganizationAccessRequestDetailsEndpoint, @@ -618,9 +615,6 @@ BroadcastDetailsEndpoint, AcceptProjectTransferEndpoint, AcceptOrganizationInvite, - MonitorDetailsEndpoint, - MonitorCheckInsEndpoint, - MonitorCheckInDetailsEndpoint, MonitorStatsEndpoint, UserIndexEndpoint, UserDetailsEndpoint,