Skip to content

Commit

Permalink
Adds initial documentation for sentry app details endpoints
Browse files Browse the repository at this point in the history
  • Loading branch information
GabeVillalobos committed Nov 8, 2024
1 parent 09f75b6 commit a24fc07
Show file tree
Hide file tree
Showing 5 changed files with 180 additions and 44 deletions.
11 changes: 11 additions & 0 deletions src/sentry/apidocs/parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 a custom integration.",
)


class VisibilityParams:
QUERY = OpenApiParameter(
name="query",
Expand Down
47 changes: 44 additions & 3 deletions src/sentry/sentry_apps/api/endpoints/sentry_app_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import orjson
import sentry_sdk
from django.db import router, transaction
from drf_spectacular.utils import extend_schema
from requests import RequestException
from rest_framework.request import Request
from rest_framework.response import Response
Expand All @@ -12,6 +13,8 @@
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import control_silo_endpoint
from sentry.api.serializers import serialize
from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN, RESPONSE_NO_CONTENT
from sentry.apidocs.parameters import SentryAppParams
from sentry.auth.staff import is_active_staff
from sentry.constants import SentryAppStatus
from sentry.organizations.services.organization import organization_service
Expand Down Expand Up @@ -42,17 +45,30 @@ class SentryAppDetailsEndpointPermission(SentryAppAndStaffPermission):
staff_allowed_methods = {"GET", "PUT"}


@extend_schema(tags=["Integration"])
@control_silo_endpoint
class SentryAppDetailsEndpoint(SentryAppBaseEndpoint):
owner = ApiOwner.INTEGRATIONS
publish_status = {
"DELETE": ApiPublishStatus.UNKNOWN,
"GET": ApiPublishStatus.UNKNOWN,
"PUT": ApiPublishStatus.UNKNOWN,
"DELETE": ApiPublishStatus.PUBLIC,
"GET": ApiPublishStatus.PUBLIC,
"PUT": ApiPublishStatus.PUBLIC,
}
permission_classes = (SentryAppDetailsEndpointPermission,)

@extend_schema(
operation_id="Update an existing custom integration.",
parameters=[
SentryAppParams.SENTRY_APP_ID_OR_SLUG,
],
responses={
200: ResponseSentryAppSerializer,
},
)
def get(self, request: Request, sentry_app) -> Response:
"""
Update an existing custom integration.
"""
return Response(
serialize(
sentry_app,
Expand All @@ -62,8 +78,23 @@ def get(self, request: Request, sentry_app) -> Response:
)
)

@extend_schema(
operation_id="Update an existing custom integration.",
parameters=[
SentryAppParams.SENTRY_APP_ID_OR_SLUG,
],
request=SentryAppParser,
responses={
200: ResponseSentryAppSerializer,
400: RESPONSE_BAD_REQUEST,
403: RESPONSE_FORBIDDEN,
},
)
@catch_raised_errors
def put(self, request: Request, sentry_app) -> Response:
"""
Update an existing custom integration.
"""
if sentry_app.metadata.get("partnership_restricted", False):
return Response(
{"detail": PARTNERSHIP_RESTRICTED_ERROR_MESSAGE},
Expand Down Expand Up @@ -151,7 +182,17 @@ def put(self, request: Request, sentry_app) -> Response:

return Response(serializer.errors, status=400)

@extend_schema(
operation_id="Delete a custom integration.",
parameters=[
SentryAppParams.SENTRY_APP_ID_OR_SLUG,
],
responses={204: RESPONSE_NO_CONTENT, 403: RESPONSE_FORBIDDEN},
)
def delete(self, request: Request, sentry_app) -> Response:
"""
Delete a custom integration.
"""
if sentry_app.metadata.get("partnership_restricted", False):
return Response(
{"detail": PARTNERSHIP_RESTRICTED_ERROR_MESSAGE},
Expand Down
70 changes: 55 additions & 15 deletions src/sentry/sentry_apps/api/parsers/sentry_app.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -66,36 +72,70 @@ def to_internal_value(self, url):
return url


@extend_schema_serializer()
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 author."
)
scopes = ApiScopesField(
allow_null=True, help_text="The permission scopes for the customer integration."
)
status = serializers.CharField(
required=False, allow_null=True, help_text="The custom integration 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="??")
overview = serializers.CharField(
required=False, allow_null=True, help_text="A description of the custom integration."
)
verifyInstall = serializers.BooleanField(
required=False, default=True, help_text="Whether or not an installation will 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="???"
)
# Bounds chosen to match PositiveSmallIntegerField (https://docs.djangoproject.com/en/3.2/ref/models/fields/#positivesmallintegerfield)
popularity = serializers.IntegerField(
min_value=0,
max_value=32767,
required=False,
allow_null=True,
help_text="The general popularity of the integration.",
)

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
Expand Down
82 changes: 59 additions & 23 deletions src/sentry/sentry_apps/api/serializers/sentry_app.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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):
Expand Down Expand Up @@ -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", [])]
Expand Down
14 changes: 11 additions & 3 deletions src/sentry/sentry_apps/api/serializers/sentry_app_avatar.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down

0 comments on commit a24fc07

Please sign in to comment.