Skip to content

Commit fd59e87

Browse files
feat(integration): Add endpoint to validate a manually entered channel (#101508)
**Problem** The Slack API has some limitations (see the [PR](#101321 (review)) discussion). We list up to 1,000 channels in the dropdown, but users might not find the one they want. To address this, we will allow users to manually enter a channel. If the channel was typed instead of selected from the list, we want to validate it on the component’s onBlur event. **Solution** We’re introducing a separate endpoint specifically for channel validation, since the form might not be complete and we cannot fire the same request used when submitting the form. This validation may not be necessary for MS Teams or Discord, but we can include it for consistency. Contributes to https://linear.app/getsentry/issue/TET-1229/implement-dropdown-or-validation-for-slack-field
1 parent 01aae6f commit fd59e87

File tree

4 files changed

+245
-0
lines changed

4 files changed

+245
-0
lines changed

src/sentry/api/urls.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,9 @@
243243
from sentry.integrations.api.endpoints.organization_config_integrations import (
244244
OrganizationConfigIntegrationsEndpoint,
245245
)
246+
from sentry.integrations.api.endpoints.organization_integration_channel_validate import (
247+
OrganizationIntegrationChannelValidateEndpoint,
248+
)
246249
from sentry.integrations.api.endpoints.organization_integration_channels import (
247250
OrganizationIntegrationChannelsEndpoint,
248251
)
@@ -1902,6 +1905,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
19021905
OrganizationIntegrationChannelsEndpoint.as_view(),
19031906
name="sentry-api-0-organization-integration-channels",
19041907
),
1908+
re_path(
1909+
r"^(?P<organization_id_or_slug>[^/]+)/integrations/(?P<integration_id>[^/]+)/channel-validate/$",
1910+
OrganizationIntegrationChannelValidateEndpoint.as_view(),
1911+
name="sentry-api-0-organization-integration-channel-validate",
1912+
),
19051913
re_path(
19061914
r"^(?P<organization_id_or_slug>[^/]+)/integrations/(?P<integration_id>[^/]+)/issues/$",
19071915
OrganizationIntegrationIssuesEndpoint.as_view(),
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
import sentry_sdk
6+
from django.core.exceptions import ValidationError
7+
from rest_framework import serializers, status
8+
from rest_framework.request import Request
9+
from rest_framework.response import Response
10+
from slack_sdk.errors import SlackApiError
11+
12+
from sentry.api.api_owners import ApiOwner
13+
from sentry.api.api_publish_status import ApiPublishStatus
14+
from sentry.api.base import control_silo_endpoint
15+
from sentry.integrations.api.bases.organization_integrations import (
16+
OrganizationIntegrationBaseEndpoint,
17+
)
18+
from sentry.integrations.discord.utils.channel import (
19+
validate_channel_id as discord_validate_channel_id,
20+
)
21+
from sentry.integrations.discord.utils.channel_from_url import (
22+
get_channel_id_from_url as discord_get_channel_id_from_url,
23+
)
24+
from sentry.integrations.msteams.utils import find_channel_id as msteams_find_channel_id
25+
from sentry.integrations.slack.utils.channel import get_channel_id
26+
from sentry.integrations.types import IntegrationProviderSlug
27+
from sentry.shared_integrations.exceptions import ApiError
28+
29+
30+
@control_silo_endpoint
31+
class OrganizationIntegrationChannelValidateEndpoint(OrganizationIntegrationBaseEndpoint):
32+
publish_status = {"GET": ApiPublishStatus.PRIVATE}
33+
owner = ApiOwner.TELEMETRY_EXPERIENCE
34+
35+
class ChannelValidateSerializer(serializers.Serializer):
36+
channel = serializers.CharField(required=True, allow_blank=False)
37+
38+
def get(
39+
self,
40+
request: Request,
41+
organization_context: Any,
42+
integration_id: int,
43+
**kwargs: Any,
44+
) -> Response:
45+
"""Validate whether a channel exists for the given integration."""
46+
serializer = self.ChannelValidateSerializer(data=request.GET)
47+
if not serializer.is_valid():
48+
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
49+
50+
channel = serializer.validated_data["channel"].strip()
51+
integration = self.get_integration(organization_context.organization.id, integration_id)
52+
53+
provider = integration.provider
54+
55+
try:
56+
if provider == IntegrationProviderSlug.SLACK.value:
57+
channel_data = get_channel_id(integration=integration, channel_name=channel)
58+
return Response({"valid": bool(channel_data.channel_id)})
59+
60+
elif provider == IntegrationProviderSlug.MSTEAMS.value:
61+
channel_id = msteams_find_channel_id(integration=integration, name=channel)
62+
return Response({"valid": bool(channel_id)})
63+
64+
elif provider == IntegrationProviderSlug.DISCORD.value:
65+
channel_id = (
66+
channel if channel.isdigit() else discord_get_channel_id_from_url(channel)
67+
)
68+
discord_validate_channel_id(
69+
channel_id=channel_id,
70+
guild_id=str(integration.external_id),
71+
guild_name=integration.name,
72+
)
73+
return Response({"valid": True})
74+
75+
return Response(
76+
{"valid": False, "detail": "Unsupported provider"},
77+
status=status.HTTP_400_BAD_REQUEST,
78+
)
79+
80+
except (SlackApiError, ApiError, ValidationError):
81+
return Response({"valid": False})
82+
except Exception as e:
83+
sentry_sdk.capture_message(f"Unexpected {provider} channel validation error")
84+
sentry_sdk.capture_exception(e)
85+
return Response({"valid": False, "detail": "Unexpected error"})

static/app/data/controlsiloUrlPatterns.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ const patterns: RegExp[] = [
6363
new RegExp('^api/0/organizations/[^/]+/integrations/$'),
6464
new RegExp('^api/0/organizations/[^/]+/integrations/[^/]+/$'),
6565
new RegExp('^api/0/organizations/[^/]+/integrations/[^/]+/channels/$'),
66+
new RegExp('^api/0/organizations/[^/]+/integrations/[^/]+/channel-validate/$'),
6667
new RegExp('^api/0/organizations/[^/]+/sentry-app-installations/$'),
6768
new RegExp('^api/0/organizations/[^/]+/sentry-apps/$'),
6869
new RegExp('^api/0/organizations/[^/]+/sentry-app-components/$'),
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
from __future__ import annotations
2+
3+
from unittest.mock import patch
4+
5+
from django.core.exceptions import ValidationError
6+
7+
from sentry.shared_integrations.exceptions import ApiError
8+
from sentry.testutils.cases import APITestCase
9+
from sentry.testutils.silo import control_silo_test
10+
11+
12+
class BaseChannelValidateTest(APITestCase):
13+
endpoint = "sentry-api-0-organization-integration-channel-validate"
14+
method = "get"
15+
16+
def setUp(self) -> None:
17+
super().setUp()
18+
self.login_as(self.user)
19+
20+
def _get_response(self, integration_id, channel):
21+
return self.get_success_response(self.organization.slug, integration_id, channel=channel)
22+
23+
24+
@control_silo_test
25+
class SlackChannelValidateTest(BaseChannelValidateTest):
26+
def setUp(self):
27+
super().setUp()
28+
self.integration = self.create_integration(
29+
organization=self.organization,
30+
provider="slack",
31+
name="Slack",
32+
external_id="slack:1",
33+
)
34+
35+
@patch(
36+
"sentry.integrations.api.endpoints.organization_integration_channel_validate.get_channel_id"
37+
)
38+
def test_channel_validation(self, mock_get_channel_id):
39+
cases = [("#general", "C123", True), ("#missing", None, False)]
40+
41+
for channel_name, channel_id, expected in cases:
42+
mock_obj = type("SlackChannelData", (), {"channel_id": channel_id})()
43+
mock_get_channel_id.return_value = mock_obj
44+
resp = self._get_response(self.integration.id, channel_name)
45+
assert resp.data["valid"] is expected
46+
47+
48+
@control_silo_test
49+
class MsTeamsChannelValidateTest(BaseChannelValidateTest):
50+
def setUp(self):
51+
super().setUp()
52+
self.integration = self.create_integration(
53+
organization=self.organization,
54+
provider="msteams",
55+
name="Teams",
56+
external_id="teams:1",
57+
)
58+
59+
@patch(
60+
"sentry.integrations.api.endpoints.organization_integration_channel_validate.msteams_find_channel_id"
61+
)
62+
def test_channel_validation(self, mock_find):
63+
cases = [("General", "19:abc@thread.tacv2", True), ("Missing", None, False)]
64+
65+
for channel_name, channel_id, expected in cases:
66+
mock_find.return_value = channel_id
67+
resp = self._get_response(self.integration.id, channel_name)
68+
assert resp.data["valid"] is expected
69+
70+
71+
@control_silo_test
72+
class DiscordChannelValidateTest(BaseChannelValidateTest):
73+
def setUp(self):
74+
super().setUp()
75+
self.integration = self.create_integration(
76+
organization=self.organization,
77+
provider="discord",
78+
name="Discord Server",
79+
external_id="1234567890",
80+
)
81+
82+
@patch(
83+
"sentry.integrations.api.endpoints.organization_integration_channel_validate.discord_validate_channel_id"
84+
)
85+
def test_discord_valid_numeric_id(self, mock_validate):
86+
mock_validate.return_value = "123"
87+
resp = self._get_response(self.integration.id, "123")
88+
assert resp.data == {"valid": True}
89+
mock_validate.assert_called_once()
90+
91+
@patch(
92+
"sentry.integrations.api.endpoints.organization_integration_channel_validate.discord_validate_channel_id",
93+
side_effect=ValidationError("bad"),
94+
)
95+
def test_discord_invalid_plain_name(self, _mock_validate):
96+
resp = self._get_response(self.integration.id, "alerts")
97+
assert resp.data == {"valid": False}
98+
99+
@patch(
100+
"sentry.integrations.api.endpoints.organization_integration_channel_validate.discord_validate_channel_id"
101+
)
102+
@patch(
103+
"sentry.integrations.api.endpoints.organization_integration_channel_validate.discord_get_channel_id_from_url"
104+
)
105+
def test_discord_valid_url(self, mock_parse, mock_validate):
106+
mock_parse.return_value = "123"
107+
mock_validate.return_value = object()
108+
resp = self._get_response(self.integration.id, "https://discord.com/channels/123/123")
109+
assert resp.data == {"valid": True}
110+
mock_parse.assert_called_once()
111+
mock_validate.assert_called_once()
112+
113+
@patch(
114+
"sentry.integrations.api.endpoints.organization_integration_channel_validate.discord_get_channel_id_from_url",
115+
side_effect=ValidationError("bad"),
116+
)
117+
def test_discord_invalid_url_parse(self, _mock_parse):
118+
resp = self._get_response(self.integration.id, "https://discord.com/channels/bad")
119+
assert resp.data == {"valid": False}
120+
121+
@patch(
122+
"sentry.integrations.api.endpoints.organization_integration_channel_validate.discord_validate_channel_id",
123+
side_effect=ApiError("rate limited", code=429),
124+
)
125+
def test_discord_validate_api_error(self, _mock_validate):
126+
resp = self._get_response(self.integration.id, "123")
127+
assert resp.data == {"valid": False}
128+
129+
130+
@control_silo_test
131+
class ChannelValidateErrorCasesTest(BaseChannelValidateTest):
132+
def test_missing_channel_param(self):
133+
integration = self.create_integration(
134+
organization=self.organization, provider="slack", name="Slack", external_id="slack:1"
135+
)
136+
resp = self.get_error_response(self.organization.slug, integration.id, status_code=400)
137+
assert "channel" in resp.data
138+
139+
def test_integration_not_found(self):
140+
resp = self.get_error_response(self.organization.slug, 99999, status_code=404, channel="#x")
141+
assert resp.status_code == 404
142+
143+
def test_unsupported_provider(self):
144+
integration = self.create_integration(
145+
organization=self.organization, provider="github", name="GitHub", external_id="github:1"
146+
)
147+
resp = self.get_error_response(
148+
self.organization.slug, integration.id, status_code=400, channel="#x"
149+
)
150+
assert resp.data["valid"] is False
151+
assert "Unsupported provider" in resp.data.get("detail", "")

0 commit comments

Comments
 (0)