Skip to content

Commit ae94f94

Browse files
liuirene256Irene Liu
andauthored
feat(integrations): Data Forwarding Endpoint to retrieve/create org-level configs (#101047)
Retrieve/Create functionality for org level configs. "GET" requires read and gets all the configs for that org (including project overrides). "POST" requires write and adds a new data forwarding config --------- Co-authored-by: Irene Liu <[email protected]>
1 parent e3fd9f3 commit ae94f94

File tree

7 files changed

+458
-2
lines changed

7 files changed

+458
-2
lines changed

src/sentry/api/urls.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@
218218
TeamAlertsTriggeredTotalsEndpoint,
219219
)
220220
from sentry.insights.endpoints.starred_segments import InsightsStarredSegmentsEndpoint
221+
from sentry.integrations.api.endpoints.data_forwarding_index import DataForwardingIndexEndpoint
221222
from sentry.integrations.api.endpoints.doc_integration_avatar import DocIntegrationAvatarEndpoint
222223
from sentry.integrations.api.endpoints.doc_integration_details import DocIntegrationDetailsEndpoint
223224
from sentry.integrations.api.endpoints.doc_integrations_index import DocIntegrationsEndpoint
@@ -1403,6 +1404,12 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
14031404
OrganizationCodeMappingCodeOwnersEndpoint.as_view(),
14041405
name="sentry-api-0-organization-code-mapping-codeowners",
14051406
),
1407+
# Data Forwarding
1408+
re_path(
1409+
r"^(?P<organization_id_or_slug>[^/]+)/forwarding/$",
1410+
DataForwardingIndexEndpoint.as_view(),
1411+
name="sentry-api-0-organization-forwarding",
1412+
),
14061413
re_path(
14071414
r"^(?P<organization_id_or_slug>[^/]+)/codeowners-associations/$",
14081415
OrganizationCodeOwnersAssociationsEndpoint.as_view(),
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
from django.utils.decorators import method_decorator
2+
from django.views.decorators.cache import never_cache
3+
from drf_spectacular.utils import extend_schema
4+
from rest_framework import status
5+
from rest_framework.request import Request
6+
from rest_framework.response import Response
7+
8+
from sentry.api.api_owners import ApiOwner
9+
from sentry.api.api_publish_status import ApiPublishStatus
10+
from sentry.api.base import region_silo_endpoint
11+
from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission
12+
from sentry.api.paginator import OffsetPaginator
13+
from sentry.api.serializers import serialize
14+
from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_CONFLICT, RESPONSE_FORBIDDEN
15+
from sentry.apidocs.parameters import GlobalParams
16+
from sentry.integrations.api.serializers.models.data_forwarder import (
17+
DataForwarderSerializer as DataForwarderModelSerializer,
18+
)
19+
from sentry.integrations.api.serializers.rest_framework.data_forwarder import (
20+
DataForwarderSerializer,
21+
)
22+
from sentry.integrations.models.data_forwarder import DataForwarder
23+
from sentry.web.decorators import set_referrer_policy
24+
25+
26+
class OrganizationDataForwardingDetailsPermission(OrganizationPermission):
27+
scope_map = {
28+
"GET": ["org:read"],
29+
"POST": ["org:write"],
30+
}
31+
32+
33+
@region_silo_endpoint
34+
@extend_schema(tags=["Integrations"])
35+
class DataForwardingIndexEndpoint(OrganizationEndpoint):
36+
owner = ApiOwner.INTEGRATIONS
37+
publish_status = {
38+
"GET": ApiPublishStatus.EXPERIMENTAL,
39+
"POST": ApiPublishStatus.EXPERIMENTAL,
40+
}
41+
permission_classes = (OrganizationDataForwardingDetailsPermission,)
42+
43+
@extend_schema(
44+
operation_id="Retrieve Data Forwarding Configurations for an Organization",
45+
parameters=[GlobalParams.ORG_ID_OR_SLUG],
46+
responses={
47+
200: DataForwarderModelSerializer,
48+
},
49+
)
50+
@set_referrer_policy("strict-origin-when-cross-origin")
51+
@method_decorator(never_cache)
52+
def get(self, request: Request, organization) -> Response:
53+
queryset = DataForwarder.objects.filter(organization_id=organization.id)
54+
55+
return self.paginate(
56+
request=request,
57+
queryset=queryset,
58+
on_results=lambda x: serialize(x, request.user),
59+
paginator_cls=OffsetPaginator,
60+
)
61+
62+
@extend_schema(
63+
operation_id="Create a Data Forwarding Configuration for an Organization",
64+
parameters=[GlobalParams.ORG_ID_OR_SLUG],
65+
request=DataForwarderSerializer,
66+
responses={
67+
201: DataForwarderModelSerializer,
68+
400: RESPONSE_BAD_REQUEST,
69+
403: RESPONSE_FORBIDDEN,
70+
409: RESPONSE_CONFLICT,
71+
},
72+
)
73+
@set_referrer_policy("strict-origin-when-cross-origin")
74+
@method_decorator(never_cache)
75+
def post(self, request: Request, organization) -> Response:
76+
data = request.data
77+
data["organization_id"] = organization.id
78+
79+
serializer = DataForwarderSerializer(data=data)
80+
if not serializer.is_valid():
81+
return self.respond(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
82+
83+
return self.respond(
84+
serialize(serializer.save(), request.user), status=status.HTTP_201_CREATED
85+
)

src/sentry/integrations/api/serializers/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from sentry.integrations.api.serializers.models.data_forwarder import * # noqa: F401,F403
12
from sentry.integrations.api.serializers.models.doc_integration import * # noqa: F401,F403
23
from sentry.integrations.api.serializers.models.doc_integration_avatar import * # noqa: F401,F403
34
from sentry.integrations.api.serializers.models.external_actor import * # noqa: F401,F403

src/sentry/integrations/api/serializers/rest_framework/data_forwarder.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import re
2-
from collections.abc import MutableMapping
2+
from collections.abc import Mapping, MutableMapping
33
from typing import Any, TypedDict
44

55
from rest_framework import serializers
@@ -9,6 +9,7 @@
99
from sentry.integrations.models.data_forwarder import DataForwarder
1010
from sentry.integrations.models.data_forwarder_project import DataForwarderProject
1111
from sentry.integrations.types import DataForwarderProviderSlug
12+
from sentry.models.project import Project
1213
from sentry_plugins.amazon_sqs.plugin import get_regions
1314

1415

@@ -124,7 +125,7 @@ def _validate_splunk_token_format(self, config: dict, errors: list[str]) -> None
124125
if not re.match(splunk_token_pattern, token):
125126
errors.append("token must be a valid Splunk HEC token format")
126127

127-
def validate(self, attrs: MutableMapping[str, Any]) -> MutableMapping[str, Any]:
128+
def validate(self, attrs: Mapping[str, Any]) -> Mapping[str, Any]:
128129
organization_id = attrs.get("organization_id")
129130
provider = attrs.get("provider")
130131

@@ -178,6 +179,27 @@ def _validate_splunk_config(self, config) -> SplunkConfig:
178179

179180
return config
180181

182+
def create(self, validated_data: Mapping[str, Any]) -> DataForwarder:
183+
data_forwarder = DataForwarder.objects.create(**validated_data)
184+
185+
# Auto-enroll all existing projects in the organization
186+
project_ids = Project.objects.filter(
187+
organization_id=validated_data["organization_id"]
188+
).values_list("id", flat=True)
189+
190+
if project_ids:
191+
project_configs = [
192+
DataForwarderProject(
193+
data_forwarder=data_forwarder,
194+
project_id=project_id,
195+
is_enabled=True,
196+
)
197+
for project_id in project_ids
198+
]
199+
DataForwarderProject.objects.bulk_create(project_configs, ignore_conflicts=True)
200+
201+
return data_forwarder
202+
181203

182204
class DataForwarderProjectSerializer(Serializer):
183205
data_forwarder_id = serializers.IntegerField()
@@ -226,3 +248,8 @@ def validate(self, attrs: MutableMapping[str, Any]) -> MutableMapping[str, Any]:
226248
)
227249

228250
return attrs
251+
252+
def create(self, validated_data: MutableMapping[str, Any]) -> DataForwarderProject:
253+
project = validated_data.pop("project")
254+
validated_data["project_id"] = project.id
255+
return DataForwarderProject.objects.create(**validated_data)

src/sentry/testutils/factories.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
IncidentType,
5656
TriggerStatus,
5757
)
58+
from sentry.integrations.models.data_forwarder import DataForwarder
5859
from sentry.integrations.models.doc_integration import DocIntegration
5960
from sentry.integrations.models.doc_integration_avatar import DocIntegrationAvatar
6061
from sentry.integrations.models.external_actor import ExternalActor
@@ -1193,6 +1194,13 @@ def create_group_activity(group, *args, **kwargs):
11931194
def create_file(**kwargs):
11941195
return File.objects.create(**kwargs)
11951196

1197+
@staticmethod
1198+
@assume_test_silo_mode(SiloMode.REGION)
1199+
def create_data_forwarder(organization, provider, config, **kwargs):
1200+
return DataForwarder.objects.create(
1201+
organization=organization, provider=provider, config=config, **kwargs
1202+
)
1203+
11961204
@staticmethod
11971205
@assume_test_silo_mode(SiloMode.REGION)
11981206
def create_file_from_path(path, name=None, **kwargs):

src/sentry/testutils/fixtures.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,11 @@ def create_n_groups_with_hashes(
356356
def create_file(self, **kwargs):
357357
return Factories.create_file(**kwargs)
358358

359+
def create_data_forwarder(self, organization=None, *args, **kwargs):
360+
if organization is None:
361+
organization = self.organization
362+
return Factories.create_data_forwarder(organization, *args, **kwargs)
363+
359364
def create_file_from_path(self, *args, **kwargs):
360365
return Factories.create_file_from_path(*args, **kwargs)
361366

0 commit comments

Comments
 (0)