diff --git a/src/sentry/apidocs/parameters.py b/src/sentry/apidocs/parameters.py index d8c911dff8a6ff..a831dc3172412e 100644 --- a/src/sentry/apidocs/parameters.py +++ b/src/sentry/apidocs/parameters.py @@ -306,6 +306,17 @@ class MetricAlertParams: ) +class SentryAppParams: + SENTRY_APP_ID_OR_SLUG = OpenApiParameter( + name="sentry_app_id_or_slug", + location="path", + required=True, + many=False, + type=str, + description="The ID or slug of the custom integration.", + ) + + class VisibilityParams: QUERY = OpenApiParameter( name="query", diff --git a/src/sentry/sentry_apps/api/parsers/sentry_app.py b/src/sentry/sentry_apps/api/parsers/sentry_app.py index c2b987e4270bb5..0fecfa235e3893 100644 --- a/src/sentry/sentry_apps/api/parsers/sentry_app.py +++ b/src/sentry/sentry_apps/api/parsers/sentry_app.py @@ -1,8 +1,11 @@ +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field, extend_schema_serializer from jsonschema.exceptions import ValidationError as SchemaValidationError from rest_framework import serializers from rest_framework.serializers import Serializer, ValidationError from sentry.api.serializers.rest_framework.base import camel_to_snake_case +from sentry.apidocs.parameters import build_typed_list from sentry.integrations.models.integration_feature import Feature from sentry.models.apiscopes import ApiScopes from sentry.sentry_apps.api.parsers.schema import validate_ui_element_schema @@ -13,6 +16,7 @@ ) +@extend_schema_field(build_typed_list(OpenApiTypes.STR)) class ApiScopesField(serializers.Field): def to_internal_value(self, data): valid_scopes = ApiScopes() @@ -26,6 +30,7 @@ def to_internal_value(self, data): return data +@extend_schema_field(build_typed_list(OpenApiTypes.STR)) class EventListField(serializers.Field): def to_internal_value(self, data): if data is None: @@ -40,6 +45,7 @@ def to_internal_value(self, data): return data +@extend_schema_field(OpenApiTypes.OBJECT) class SchemaField(serializers.Field): def to_internal_value(self, data): if data is None: @@ -66,24 +72,63 @@ def to_internal_value(self, url): return url +@extend_schema_serializer(exclude_fields=["popularity"]) class SentryAppParser(Serializer): - name = serializers.CharField() - author = serializers.CharField(required=False, allow_null=True) - scopes = ApiScopesField(allow_null=True) - status = serializers.CharField(required=False, allow_null=True) - events = EventListField(required=False, allow_null=True) + name = serializers.CharField(help_text="The name of the custom integration.") + author = serializers.CharField( + required=False, allow_null=True, help_text="The custom integration's author." + ) + scopes = ApiScopesField( + allow_null=True, help_text="The custom integration's permission scopes for API access." + ) + status = serializers.CharField( + required=False, allow_null=True, help_text="The custom integration's status." + ) + events = EventListField( + required=False, + allow_null=True, + help_text="Webhook events the custom integration is subscribed to.", + ) features = serializers.MultipleChoiceField( - choices=Feature.as_choices(), allow_blank=True, allow_null=True, required=False + choices=Feature.as_choices(), + allow_blank=True, + allow_null=True, + required=False, + help_text="The features available via the custom integration", + ) + schema = SchemaField(required=False, allow_null=True, help_text="??") + webhookUrl = URLField( + required=False, + allow_null=True, + allow_blank=True, + help_text="The URL where webhook events will be sent.", + ) + redirectUrl = URLField( + required=False, + allow_null=True, + allow_blank=True, + help_text="The authentication redirect URL.", + ) + isInternal = serializers.BooleanField( + required=False, + default=False, + help_text="Whether or not the integration is internal only. False means the integration is public.", + ) + isAlertable = serializers.BooleanField( + required=False, + default=False, + help_text="Marks whether or not the custom integration can be used in an alert rule.", + ) + overview = serializers.CharField( + required=False, allow_null=True, help_text="The custom integration's description." + ) + verifyInstall = serializers.BooleanField( + required=False, + default=True, + help_text="Whether or not an installation of the custom integration should be verified.", ) - schema = SchemaField(required=False, allow_null=True) - webhookUrl = URLField(required=False, allow_null=True, allow_blank=True) - redirectUrl = URLField(required=False, allow_null=True, allow_blank=True) - isInternal = serializers.BooleanField(required=False, default=False) - isAlertable = serializers.BooleanField(required=False, default=False) - overview = serializers.CharField(required=False, allow_null=True) - verifyInstall = serializers.BooleanField(required=False, default=True) allowedOrigins = serializers.ListField( - child=serializers.CharField(max_length=255), required=False + child=serializers.CharField(max_length=255), required=False, help_text="FILL THIS IN" ) # Bounds chosen to match PositiveSmallIntegerField (https://docs.djangoproject.com/en/3.2/ref/models/fields/#positivesmallintegerfield) popularity = serializers.IntegerField( @@ -95,7 +140,7 @@ class SentryAppParser(Serializer): def __init__(self, *args, **kwargs): self.active_staff = kwargs.pop("active_staff", False) - self.access = kwargs.pop("access") + self.access = kwargs.pop("access", None) Serializer.__init__(self, *args, **kwargs) # an abstraction to pull fields from attrs if they are available or the sentry_app if not diff --git a/src/sentry/sentry_apps/api/serializers/sentry_app.py b/src/sentry/sentry_apps/api/serializers/sentry_app.py index 5aede169652985..2a9f5785b4fd1d 100644 --- a/src/sentry/sentry_apps/api/serializers/sentry_app.py +++ b/src/sentry/sentry_apps/api/serializers/sentry_app.py @@ -1,6 +1,6 @@ from collections.abc import Mapping, Sequence -from datetime import timedelta -from typing import Any +from datetime import datetime, timedelta +from typing import Any, TypedDict from django.utils import timezone @@ -16,12 +16,46 @@ from sentry.sentry_apps.api.serializers.sentry_app_avatar import ( SentryAppAvatarSerializer as ResponseSentryAppAvatarSerializer, ) +from sentry.sentry_apps.api.serializers.sentry_app_avatar import SentryAppAvatarSerializerResponse from sentry.sentry_apps.models.sentry_app import MASKED_VALUE, SentryApp from sentry.sentry_apps.models.sentry_app_avatar import SentryAppAvatar from sentry.users.models.user import User from sentry.users.services.user.service import user_service +class OwnerResponseField(TypedDict): + id: int + slug: str + + +class SentryAppSerializerOptionalFields(TypedDict, total=False): + author: str | None + overview: str | None + popularity: int | None + redirectUrl: str | None + webhookUrl: str | None + datePublished: datetime | None + clientSecret: str | None + clientId: str | None + owner: OwnerResponseField | None + + +class SentryAppSerializerResponse(SentryAppSerializerOptionalFields): + allowedOrigins: list[str] + avatars: SentryAppAvatarSerializerResponse + events: set[str] + featureData: list[str] + isAlertable: bool + metadata: str + name: str + schema: str + scopes: list[str] + slug: str + status: str + uuid: str + verifyInstall: bool + + @register(SentryApp) class SentryAppSerializer(Serializer): def get_attrs(self, item_list: Sequence[SentryApp], user: User, **kwargs: Any): @@ -57,35 +91,37 @@ def get_attrs(self, item_list: Sequence[SentryApp], user: User, **kwargs: Any): for item in item_list } - def serialize(self, obj: SentryApp, attrs: Mapping[str, Any], user: User, **kwargs: Any): + def serialize( + self, obj: SentryApp, attrs: Mapping[str, Any], user: User, **kwargs: Any + ) -> SentryAppSerializerResponse: from sentry.sentry_apps.logic import consolidate_events application = attrs["application"] - data = { - "allowedOrigins": application.get_allowed_origins(), - "author": obj.author, - "avatars": serialize( + data = SentryAppSerializerResponse( + allowedOrigins=application.get_allowed_origins(), + author=obj.author, + avatars=serialize( objects=attrs.get("avatars"), user=user, serializer=ResponseSentryAppAvatarSerializer(), ), - "events": consolidate_events(obj.events), - "featureData": [], - "isAlertable": obj.is_alertable, - "metadata": obj.metadata, - "name": obj.name, - "overview": obj.overview, - "popularity": obj.popularity, - "redirectUrl": obj.redirect_url, - "schema": obj.schema, - "scopes": obj.get_scopes(), - "slug": obj.slug, - "status": obj.get_status_display(), - "uuid": obj.uuid, - "verifyInstall": obj.verify_install, - "webhookUrl": obj.webhook_url, - } + events=consolidate_events(obj.events), + featureData=[], + isAlertable=obj.is_alertable, + metadata=obj.metadata, + name=obj.name, + overview=obj.overview, + popularity=obj.popularity, + redirectUrl=obj.redirect_url, + schema=obj.schema, + scopes=obj.get_scopes(), + slug=obj.slug, + status=obj.get_status_display(), + uuid=obj.uuid, + verifyInstall=obj.verify_install, + webhookUrl=obj.webhook_url, + ) if obj.status != SentryAppStatus.INTERNAL: data["featureData"] = [serialize(x, user) for x in attrs.get("features", [])] diff --git a/src/sentry/sentry_apps/api/serializers/sentry_app_avatar.py b/src/sentry/sentry_apps/api/serializers/sentry_app_avatar.py index a6f255ca0b80ce..0b7c39a4961e12 100644 --- a/src/sentry/sentry_apps/api/serializers/sentry_app_avatar.py +++ b/src/sentry/sentry_apps/api/serializers/sentry_app_avatar.py @@ -1,13 +1,21 @@ -from collections.abc import MutableMapping -from typing import Any +from typing import TypedDict from sentry.api.serializers import Serializer, register from sentry.sentry_apps.models.sentry_app_avatar import SentryAppAvatar +class SentryAppAvatarSerializerResponse(TypedDict): + avatarType: str + avatarUuid: str + avatarUrl: str + color: bool + + @register(SentryAppAvatar) class SentryAppAvatarSerializer(Serializer): - def serialize(self, obj: SentryAppAvatar, attrs, user, **kwargs) -> MutableMapping[str, Any]: + def serialize( + self, obj: SentryAppAvatar, attrs, user, **kwargs + ) -> SentryAppAvatarSerializerResponse: return { "avatarType": obj.get_avatar_type_display(), "avatarUuid": obj.ident,