Skip to content

Commit 7440a83

Browse files
authoredAug 22, 2023
Webhooks Public API (#2790)
# What this PR does - Add public API for Webhooks CRUD, and GET webhook responses - Add insight resource logs for internal and public webhook API calls - Change public actions API to wrap Webhooks to maintain compatibility with existing callers ## Which issue(s) this PR fixes #2792 #2793 ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required)
1 parent 0dfa882 commit 7440a83

File tree

16 files changed

+986
-198
lines changed

16 files changed

+986
-198
lines changed
 

‎CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
### Added
11+
12+
- Public API for webhooks @mderynck ([#2790](https://github.com/grafana/oncall/pull/2790))
13+
1014
### Changed
1115

16+
- Public API for actions now wraps webhooks @mderynck ([#2790](https://github.com/grafana/oncall/pull/2790))
1217
- Allow mobile app to access status endpoint @mderynck ([#2791](https://github.com/grafana/oncall/pull/2791))
1318

1419
## v1.3.26 (2023-08-22)
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
---
22
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/outgoing_webhooks/
3-
title: Outgoing webhooks HTTP API
3+
title: Outgoing Webhooks HTTP API
44
weight: 700
55
---
66

7-
# Outgoing webhooks (actions)
7+
# Outgoing Webhooks
88

9-
Used in escalation policies with type `trigger_action`.
9+
> ⚠️ A note about actions: Before version **v1.3.11** webhooks existed as actions within the API, the /actions
10+
> endpoint remains available and is compatible with previous callers but under the hood it will interact with the
11+
> new webhooks objects. It is recommended to use the /webhooks endpoint going forward which has more features.
1012
11-
## List actions
13+
For more details about specific fields of a webhook see [outgoing webhooks][outgoing-webhooks] documentation.
14+
15+
## List webhooks
1216

1317
```shell
14-
curl "{{API_URL}}/api/v1/actions/" \
18+
curl "{{API_URL}}/api/v1/webhooks/" \
1519
--request GET \
1620
--header "Authorization: meowmeowmeow" \
1721
--header "Content-Type: application/json"
@@ -21,21 +25,210 @@ The above command returns JSON structured in the following way:
2125

2226
```json
2327
{
24-
"count": 1,
2528
"next": null,
2629
"previous": null,
2730
"results": [
2831
{
29-
"id": "KGEFG74LU1D8L",
30-
"name": "Publish alert group notification to JIRA"
32+
"id": "{{WEBHOOK_UID}}",
33+
"name": "Demo Webhook",
34+
"is_webhook_enabled": true,
35+
"team": null,
36+
"data": "{\"labels\" : {{ alert_payload.commonLabels | tojson()}}}",
37+
"username": null,
38+
"password": null,
39+
"authorization_header": "****************",
40+
"trigger_template": null,
41+
"headers": null,
42+
"url": "https://example.com",
43+
"forward_all": false,
44+
"http_method": "POST",
45+
"trigger_type": "acknowledge",
46+
"integration_filter": [
47+
"CRV8A5MXC751A"
48+
]
3149
}
3250
],
33-
"current_page_number": 1,
3451
"page_size": 50,
52+
"count": 1,
53+
"current_page_number": 1,
3554
"total_pages": 1
3655
}
3756
```
3857

39-
**HTTP request**
58+
## Get webhook
59+
60+
```shell
61+
curl "{{API_URL}}/api/v1/webhooks/{{WEBHOOK_UID}}/" \
62+
--request GET \
63+
--header "Authorization: meowmeowmeow" \
64+
--header "Content-Type: application/json"
65+
```
66+
67+
The above command returns JSON structured in the following way:
68+
69+
```json
70+
{
71+
"id": "{{WEBHOOK_UID}}",
72+
"name": "Demo Webhook",
73+
"is_webhook_enabled": true,
74+
"team": null,
75+
"data": "{\"labels\" : {{ alert_payload.commonLabels | tojson()}}}",
76+
"username": null,
77+
"password": null,
78+
"authorization_header": "****************",
79+
"trigger_template": null,
80+
"headers": null,
81+
"url": "https://example.com",
82+
"forward_all": false,
83+
"http_method": "POST",
84+
"trigger_type": "acknowledge",
85+
"integration_filter": [
86+
"CRV8A5MXC751A"
87+
]
88+
}
89+
```
90+
91+
## Create webhook
92+
93+
```shell
94+
curl "{{API_URL}}/api/v1/webhooks/" \
95+
--request POST \
96+
--header "Authorization: meowmeowmeow" \
97+
--header "Content-Type: application/json" \
98+
--data '{
99+
"name": "New Webhook",
100+
"url": "https://example.com",
101+
"http_method": "POST",
102+
"trigger_type" : "resolve"
103+
}'
104+
```
105+
106+
### Trigger Types
107+
108+
See [here](outgoing-webhooks#event-types) for details
109+
110+
- `escalation`
111+
- `alert group created`
112+
- `acknowledge`
113+
- `resolve`
114+
- `silence`
115+
- `unsilence`
116+
- `unresolve`
117+
- `unacknowledge`
118+
119+
### HTTP Methods
120+
121+
- `POST`
122+
- `GET`
123+
- `PUT`
124+
- `DELETE`
125+
- `OPTIONS`
126+
127+
The above command returns JSON structured in the following way:
128+
129+
```json
130+
{
131+
"id": "{{WEBHOOK_UID}}",
132+
"name": "New Webhook",
133+
"is_webhook_enabled": true,
134+
"team": null,
135+
"data": null,
136+
"username": null,
137+
"password": null,
138+
"authorization_header": null,
139+
"trigger_template": null,
140+
"headers": null,
141+
"url": "https://example.com",
142+
"forward_all": true,
143+
"http_method": "POST",
144+
"trigger_type": "resolve",
145+
"integration_filter": null
146+
}
147+
```
148+
149+
## Update webhook
150+
151+
```shell
152+
curl "{{API_URL}}/api/v1/webhooks/{{WEBHOOK_UID}}/" \
153+
--request PUT \
154+
--header "Authorization: meowmeowmeow" \
155+
--header "Content-Type: application/json" \
156+
--data '{
157+
"is_webhook_enabled": false
158+
}'
159+
```
160+
161+
The above command returns JSON structured in the following way:
162+
163+
```json
164+
{
165+
"id": "{{WEBHOOK_UID}}",
166+
"name": "New Webhook",
167+
"is_webhook_enabled": false,
168+
"team": null,
169+
"data": null,
170+
"username": null,
171+
"password": null,
172+
"authorization_header": null,
173+
"trigger_template": null,
174+
"headers": null,
175+
"url": "https://example.com",
176+
"forward_all": true,
177+
"http_method": "POST",
178+
"trigger_type": "resolve",
179+
"integration_filter": null
180+
}
181+
```
182+
183+
## Delete webhook
184+
185+
```shell
186+
curl "{{API_URL}}/api/v1/webhooks/{{WEBHOOK_UID}}/" \
187+
--request DELETE \
188+
--header "Authorization: meowmeowmeow" \
189+
--header "Content-Type: application/json"
190+
```
191+
192+
## Get webhook responses
193+
194+
```shell
195+
curl "{{API_URL}}/api/v1/webhooks/{{WEBHOOK_UID}}/responses" \
196+
--request GET \
197+
--header "Authorization: meowmeowmeow" \
198+
--header "Content-Type: application/json"
199+
```
200+
201+
The above command returns JSON structured in the following way:
40202

41-
`GET {{API_URL}}/api/v1/actions/`
203+
```json
204+
{
205+
"next": null,
206+
"previous": null,
207+
"results": [
208+
{
209+
"timestamp": "2023-08-18T16:38:23.106015Z",
210+
"url": "https://example.com",
211+
"request_trigger": "",
212+
"request_headers": "{\"Authorization\": \"****************\"}",
213+
"request_data": "{\"labels\": {\"alertname\": \"InstanceDown\", \"job\": \"node\", \"severity\": \"critical\"}}",
214+
"status_code": 200,
215+
"content": "",
216+
"event_data": "{\"event\": {\"type\": \"acknowledge\", \"time\": \"2023-08-18T16:38:21.442981+00:00\"}, \"user\": {\"id\": \"UK49JJNPZMFLJ\", \"username\": \"oncall\", \"email\": \"admin@localhost\"}, \"alert_group\": {\"id\": \"IZQERPWKWCGH1\", \"integration_id\": \"CRV8A5MXC751A\", \"route_id\": \"RWNCT6C77M3WM\", \"alerts_count\": 1, \"state\": \"acknowledged\", \"created_at\": \"2023-08-18T16:34:27.678406Z\", \"resolved_at\": null, \"acknowledged_at\": \"2023-08-18T16:38:21.442981Z\", \"title\": \"[firing:2] InstanceDown \", \"permalinks\": {\"slack\": null, \"telegram\": null, \"web\": \"http://localhost:3000/a/grafana-oncall-app/alert-groups/IZQERPWKWCGH1\"}}, \"alert_group_id\": \"IZQERPWKWCGH1\", \"alert_payload\": {\"alerts\": [{\"endsAt\": \"0001-01-01T00:00:00Z\", \"labels\": {\"job\": \"node\", \"group\": \"production\", \"instance\": \"localhost:8081\", \"severity\": \"critical\", \"alertname\": \"InstanceDown\"}, \"status\": \"firing\", \"startsAt\": \"2023-06-12T08:24:38.326Z\", \"annotations\": {\"title\": \"Instance localhost:8081 down\", \"description\": \"localhost:8081 of job node has been down for more than 1 minute.\"}, \"fingerprint\": \"f404ecabc8dd5cd7\", \"generatorURL\": \"\"}, {\"endsAt\": \"0001-01-01T00:00:00Z\", \"labels\": {\"job\": \"node\", \"group\": \"canary\", \"instance\": \"localhost:8082\", \"severity\": \"critical\", \"alertname\": \"InstanceDown\"}, \"status\": \"firing\", \"startsAt\": \"2023-06-12T08:24:38.326Z\", \"annotations\": {\"title\": \"Instance localhost:8082 down\", \"description\": \"localhost:8082 of job node has been down for more than 1 minute.\"}, \"fingerprint\": \"f8f08d4e32c61a9d\", \"generatorURL\": \"\"}], \"status\": \"firing\", \"version\": \"4\", \"groupKey\": \"{}:{alertname=\\\"InstanceDown\\\"}\", \"receiver\": \"combo\", \"numFiring\": 2, \"externalURL\": \"\", \"groupLabels\": {\"alertname\": \"InstanceDown\"}, \"numResolved\": 0, \"commonLabels\": {\"job\": \"node\", \"severity\": \"critical\", \"alertname\": \"InstanceDown\"}, \"truncatedAlerts\": 0, \"commonAnnotations\": {}}, \"integration\": {\"id\": \"CRV8A5MXC751A\", \"type\": \"alertmanager\", \"name\": \"One - Alertmanager\", \"team\": null}, \"notified_users\": [], \"users_to_be_notified\": []}"
217+
},
218+
{
219+
"timestamp": "2023-08-18T16:34:38.580574Z",
220+
"url": "https://example.com",
221+
"request_trigger": "",
222+
"request_headers": null,
223+
"request_data": "Data - Template Warning: Object of type Undefined is not JSON serializable",
224+
"status_code": null,
225+
"content": null,
226+
"event_data": "{\"event\": {\"type\": \"acknowledge\", \"time\": \"2023-08-18T16:34:37.940655+00:00\"}, \"user\": {\"id\": \"UK49JJNPZMFLJ\", \"username\": \"oncall\", \"email\": \"admin@localhost\"}, \"alert_group\": {\"id\": \"IZQERPWKWCGH1\", \"integration_id\": \"CRV8A5MXC751A\", \"route_id\": \"RWNCT6C77M3WM\", \"alerts_count\": 1, \"state\": \"acknowledged\", \"created_at\": \"2023-08-18T16:34:27.678406Z\", \"resolved_at\": null, \"acknowledged_at\": \"2023-08-18T16:34:37.940655Z\", \"title\": \"[firing:2] InstanceDown \", \"permalinks\": {\"slack\": null, \"telegram\": null, \"web\": \"http://localhost:3000/a/grafana-oncall-app/alert-groups/IZQERPWKWCGH1\"}}, \"alert_group_id\": \"IZQERPWKWCGH1\", \"alert_payload\": {\"alerts\": [{\"endsAt\": \"0001-01-01T00:00:00Z\", \"labels\": {\"job\": \"node\", \"group\": \"production\", \"instance\": \"localhost:8081\", \"severity\": \"critical\", \"alertname\": \"InstanceDown\"}, \"status\": \"firing\", \"startsAt\": \"2023-06-12T08:24:38.326Z\", \"annotations\": {\"title\": \"Instance localhost:8081 down\", \"description\": \"localhost:8081 of job node has been down for more than 1 minute.\"}, \"fingerprint\": \"f404ecabc8dd5cd7\", \"generatorURL\": \"\"}, {\"endsAt\": \"0001-01-01T00:00:00Z\", \"labels\": {\"job\": \"node\", \"group\": \"canary\", \"instance\": \"localhost:8082\", \"severity\": \"critical\", \"alertname\": \"InstanceDown\"}, \"status\": \"firing\", \"startsAt\": \"2023-06-12T08:24:38.326Z\", \"annotations\": {\"title\": \"Instance localhost:8082 down\", \"description\": \"localhost:8082 of job node has been down for more than 1 minute.\"}, \"fingerprint\": \"f8f08d4e32c61a9d\", \"generatorURL\": \"\"}], \"status\": \"firing\", \"version\": \"4\", \"groupKey\": \"{}:{alertname=\\\"InstanceDown\\\"}\", \"receiver\": \"combo\", \"numFiring\": 2, \"externalURL\": \"\", \"groupLabels\": {\"alertname\": \"InstanceDown\"}, \"numResolved\": 0, \"commonLabels\": {\"job\": \"node\", \"severity\": \"critical\", \"alertname\": \"InstanceDown\"}, \"truncatedAlerts\": 0, \"commonAnnotations\": {}}, \"integration\": {\"id\": \"CRV8A5MXC751A\", \"type\": \"alertmanager\", \"name\": \"One - Alertmanager\", \"team\": null}, \"notified_users\": [], \"users_to_be_notified\": []}"
227+
}
228+
],
229+
"page_size": 50,
230+
"count": 2,
231+
"current_page_number": 1,
232+
"total_pages": 1
233+
}
234+
```

‎docs/sources/outgoing-webhooks/_index.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@ To fix change the template to:
371371

372372
```json
373373
{
374-
"labels": "{{ alert_payload.labels | tojson()}}"
374+
"labels": {{ alert_payload.labels | tojson()}}
375375
}
376376
```
377377

‎engine/apps/api/serializers/webhook.py

-1
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ class Meta:
4444
"is_webhook_enabled",
4545
"is_legacy",
4646
"team",
47-
"data",
4847
"user",
4948
"username",
5049
"password",

‎engine/apps/api/tests/test_webhooks.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@ def test_get_list_webhooks(webhook_internal_api_setup, make_user_auth_headers):
6464
"event_data": "",
6565
},
6666
"trigger_template": None,
67-
"trigger_type": None,
68-
"trigger_type_name": "",
67+
"trigger_type": "0",
68+
"trigger_type_name": "Escalation step",
6969
}
7070
]
7171

@@ -106,8 +106,8 @@ def test_get_detail_webhook(webhook_internal_api_setup, make_user_auth_headers):
106106
"event_data": "",
107107
},
108108
"trigger_template": None,
109-
"trigger_type": None,
110-
"trigger_type_name": "",
109+
"trigger_type": "0",
110+
"trigger_type_name": "Escalation step",
111111
}
112112

113113
response = client.get(url, format="json", **make_user_auth_headers(user, token))

‎engine/apps/api/views/webhooks.py

+25
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from common.api_helpers.exceptions import BadRequest
1919
from common.api_helpers.filters import ByTeamModelFieldFilterMixin, ModelFieldFilterMixin, TeamModelMultipleChoiceFilter
2020
from common.api_helpers.mixins import PublicPrimaryKeyMixin, TeamFilteringMixin
21+
from common.insight_log import EntityEvent, write_resource_insight_log
2122
from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning
2223

2324
NEW_WEBHOOK_PK = "new"
@@ -60,6 +61,30 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet):
6061
search_fields = ["public_primary_key", "name"]
6162
filterset_class = WebhooksFilter
6263

64+
def perform_create(self, serializer):
65+
serializer.save()
66+
write_resource_insight_log(instance=serializer.instance, author=self.request.user, event=EntityEvent.CREATED)
67+
68+
def perform_update(self, serializer):
69+
prev_state = serializer.instance.insight_logs_serialized
70+
serializer.save()
71+
new_state = serializer.instance.insight_logs_serialized
72+
write_resource_insight_log(
73+
instance=serializer.instance,
74+
author=self.request.user,
75+
event=EntityEvent.UPDATED,
76+
prev_state=prev_state,
77+
new_state=new_state,
78+
)
79+
80+
def perform_destroy(self, instance):
81+
write_resource_insight_log(
82+
instance=instance,
83+
author=self.request.user,
84+
event=EntityEvent.DELETED,
85+
)
86+
instance.delete()
87+
6388
def get_queryset(self, ignore_filtering_by_available_teams=False):
6489
queryset = Webhook.objects.filter(
6590
organization=self.request.auth.organization,
+30-72
Original file line numberDiff line numberDiff line change
@@ -1,106 +1,64 @@
1-
import json
2-
from collections import defaultdict
3-
4-
from django.core.validators import URLValidator, ValidationError
5-
from jinja2 import TemplateError
61
from rest_framework import serializers
72
from rest_framework.validators import UniqueTogetherValidator
83

9-
from apps.alerts.models import CustomButton
4+
from apps.public_api.serializers.webhooks import WebhookCreateSerializer, WebhookTriggerTypeField
5+
from apps.webhooks.models import Webhook
106
from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField
11-
from common.api_helpers.utils import CurrentOrganizationDefault
12-
from common.jinja_templater import jinja_template_env
7+
from common.api_helpers.utils import CurrentTeamDefault
138

149

15-
class ActionCreateSerializer(serializers.ModelSerializer):
16-
id = serializers.CharField(read_only=True, source="public_primary_key")
17-
organization = serializers.HiddenField(default=CurrentOrganizationDefault())
18-
team_id = TeamPrimaryKeyRelatedField(required=False, allow_null=True, source="team")
19-
url = serializers.CharField(required=True, allow_null=False, allow_blank=False, source="webhook")
10+
class ActionCreateSerializer(WebhookCreateSerializer):
11+
team_id = TeamPrimaryKeyRelatedField(allow_null=True, default=CurrentTeamDefault(), source="team")
12+
user = serializers.CharField(required=False, source="username")
13+
trigger_type = WebhookTriggerTypeField(required=False)
14+
forward_whole_payload = serializers.BooleanField(required=False, source="forward_all")
2015

2116
class Meta:
22-
model = CustomButton
17+
model = Webhook
2318
fields = [
2419
"id",
2520
"name",
21+
"is_webhook_enabled",
2622
"organization",
2723
"team_id",
28-
"url",
24+
"user",
2925
"data",
3026
"user",
3127
"password",
3228
"authorization_header",
29+
"trigger_template",
30+
"headers",
31+
"url",
3332
"forward_whole_payload",
33+
"http_method",
34+
"trigger_type",
35+
"integration_filter",
3436
]
3537
extra_kwargs = {
3638
"name": {"required": True, "allow_null": False, "allow_blank": False},
37-
"data": {"required": False, "allow_null": True, "allow_blank": False},
38-
"user": {"required": False, "allow_null": True, "allow_blank": False},
39-
"password": {"required": False, "allow_null": True, "allow_blank": False},
40-
"authorization_header": {"required": False, "allow_null": True, "allow_blank": False},
41-
"forward_whole_payload": {"required": False, "allow_null": True},
39+
"url": {"required": True, "allow_null": False, "allow_blank": False},
4240
}
4341

44-
validators = [UniqueTogetherValidator(queryset=CustomButton.objects.all(), fields=["name", "organization"])]
45-
46-
def validate_url(self, url):
47-
if url:
48-
try:
49-
URLValidator()(url)
50-
except ValidationError:
51-
raise serializers.ValidationError("URL is incorrect")
52-
return url
53-
return None
54-
55-
def validate_data(self, data):
56-
if not data:
57-
return None
58-
59-
try:
60-
template = jinja_template_env.from_string(data)
61-
except TemplateError:
62-
raise serializers.ValidationError("Data has incorrect template")
63-
64-
try:
65-
rendered = template.render(
66-
{
67-
# Validate that the template can be rendered with a JSON-ish alert payload.
68-
# We don't know what the actual payload will be, so we use a defaultdict
69-
# so that attribute access within a template will never fail
70-
# (provided it's only one level deep - we won't accept templates that attempt
71-
# to do nested attribute access).
72-
# Every attribute access should return a string to ensure that users are
73-
# correctly using `tojson` or wrapping fields in strings.
74-
# If we instead used a `defaultdict(dict)` or `defaultdict(lambda: 1)` we
75-
# would accidentally accept templates such as `{"name": {{ alert_payload.name }}}`
76-
# which would then fail at the true render time due to the
77-
# lack of explicit quotes around the template variable; this would render
78-
# as `{"name": some_alert_name}` which is not valid JSON.
79-
"alert_payload": defaultdict(str),
80-
"alert_group_id": "abcd",
81-
}
82-
)
83-
json.loads(rendered)
84-
except ValueError:
85-
raise serializers.ValidationError("Data has incorrect format")
86-
87-
return data
88-
89-
def validate_forward_whole_payload(self, data):
90-
if data is None:
91-
return False
92-
return data
42+
validators = [UniqueTogetherValidator(queryset=Webhook.objects.all(), fields=["name", "organization"])]
9343

9444

9545
class ActionUpdateSerializer(ActionCreateSerializer):
96-
url = serializers.CharField(required=False, allow_null=False, allow_blank=False, source="webhook")
46+
user = serializers.CharField(required=False, source="username")
47+
trigger_type = WebhookTriggerTypeField(required=False)
48+
forward_whole_payload = serializers.BooleanField(required=False, source="forward_all")
9749

9850
class Meta(ActionCreateSerializer.Meta):
9951
extra_kwargs = {
10052
"name": {"required": False, "allow_null": False, "allow_blank": False},
101-
"data": {"required": False, "allow_null": True, "allow_blank": False},
53+
"is_webhook_enabled": {"required": False, "allow_null": False},
10254
"user": {"required": False, "allow_null": True, "allow_blank": False},
10355
"password": {"required": False, "allow_null": True, "allow_blank": False},
10456
"authorization_header": {"required": False, "allow_null": True, "allow_blank": False},
105-
"forward_whole_payload": {"required": False, "allow_null": True},
57+
"trigger_template": {"required": False, "allow_null": True, "allow_blank": False},
58+
"headers": {"required": False, "allow_null": True, "allow_blank": False},
59+
"url": {"required": False, "allow_null": False, "allow_blank": False},
60+
"data": {"required": False, "allow_null": True, "allow_blank": False},
61+
"forward_whole_payload": {"required": False, "allow_null": False},
62+
"http_method": {"required": False, "allow_null": False, "allow_blank": False},
63+
"integration_filter": {"required": False, "allow_null": True},
10664
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
from collections import defaultdict
2+
3+
from rest_framework import fields, serializers
4+
from rest_framework.validators import UniqueTogetherValidator
5+
6+
from apps.alerts.models import AlertReceiveChannel
7+
from apps.webhooks.models import Webhook, WebhookResponse
8+
from apps.webhooks.models.webhook import PUBLIC_WEBHOOK_HTTP_METHODS, WEBHOOK_FIELD_PLACEHOLDER
9+
from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField
10+
from common.api_helpers.exceptions import BadRequest
11+
from common.api_helpers.utils import CurrentOrganizationDefault, CurrentTeamDefault, CurrentUserDefault
12+
from common.jinja_templater import apply_jinja_template
13+
from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning
14+
15+
INTEGRATION_FILTER_MESSAGE = "integration_filter must be a list of valid integration ids"
16+
17+
18+
class WebhookTriggerTypeField(fields.CharField):
19+
def to_representation(self, value):
20+
return Webhook.PUBLIC_TRIGGER_TYPES_MAP[value]
21+
22+
def to_internal_value(self, data):
23+
try:
24+
trigger_type = [
25+
key
26+
for key, value in Webhook.PUBLIC_TRIGGER_TYPES_MAP.items()
27+
if value == data and key in Webhook.PUBLIC_TRIGGER_TYPES_MAP
28+
][0]
29+
except IndexError:
30+
raise BadRequest(detail=f"trigger_type must one of {Webhook.PUBLIC_ALL_TRIGGER_TYPES}")
31+
return trigger_type
32+
33+
34+
class WebhookResponseSerializer(serializers.ModelSerializer):
35+
class Meta:
36+
model = WebhookResponse
37+
fields = [
38+
"timestamp",
39+
"url",
40+
"request_trigger",
41+
"request_headers",
42+
"request_data",
43+
"status_code",
44+
"content",
45+
"event_data",
46+
]
47+
48+
49+
class WebhookCreateSerializer(serializers.ModelSerializer):
50+
id = serializers.CharField(read_only=True, source="public_primary_key")
51+
organization = serializers.HiddenField(default=CurrentOrganizationDefault())
52+
team = TeamPrimaryKeyRelatedField(allow_null=True, default=CurrentTeamDefault())
53+
user = serializers.HiddenField(default=CurrentUserDefault())
54+
trigger_type = WebhookTriggerTypeField()
55+
56+
class Meta:
57+
model = Webhook
58+
fields = [
59+
"id",
60+
"name",
61+
"is_webhook_enabled",
62+
"organization",
63+
"team",
64+
"user",
65+
"data",
66+
"username",
67+
"password",
68+
"authorization_header",
69+
"trigger_template",
70+
"headers",
71+
"url",
72+
"forward_all",
73+
"http_method",
74+
"trigger_type",
75+
"integration_filter",
76+
]
77+
extra_kwargs = {
78+
"name": {"required": True, "allow_null": False, "allow_blank": False},
79+
"url": {"required": True, "allow_null": False, "allow_blank": False},
80+
"http_method": {"required": True, "allow_null": False, "allow_blank": False},
81+
}
82+
83+
validators = [UniqueTogetherValidator(queryset=Webhook.objects.all(), fields=["name", "organization"])]
84+
85+
def to_representation(self, instance):
86+
result = super().to_representation(instance)
87+
if instance.password:
88+
result["password"] = WEBHOOK_FIELD_PLACEHOLDER
89+
if instance.authorization_header:
90+
result["authorization_header"] = WEBHOOK_FIELD_PLACEHOLDER
91+
return result
92+
93+
def to_internal_value(self, data):
94+
webhook = self.instance
95+
if data.get("password") == WEBHOOK_FIELD_PLACEHOLDER:
96+
data["password"] = webhook.password
97+
if data.get("authorization_header") == WEBHOOK_FIELD_PLACEHOLDER:
98+
data["authorization_header"] = webhook.authorization_header
99+
return super().to_internal_value(data)
100+
101+
def _validate_template_field(self, template):
102+
try:
103+
apply_jinja_template(template, alert_payload=defaultdict(str), alert_group_id="alert_group_1")
104+
except JinjaTemplateError as e:
105+
raise serializers.ValidationError(e.fallback_message)
106+
except JinjaTemplateWarning:
107+
# Suppress render exceptions since we do not have a representative payload to test with
108+
pass
109+
return template
110+
111+
def validate_trigger_template(self, trigger_template):
112+
if not trigger_template:
113+
return None
114+
return self._validate_template_field(trigger_template)
115+
116+
def validate_headers(self, headers):
117+
if not headers:
118+
return None
119+
return self._validate_template_field(headers)
120+
121+
def validate_url(self, url):
122+
if not url:
123+
return None
124+
return self._validate_template_field(url)
125+
126+
def validate_data(self, data):
127+
if not data:
128+
return None
129+
return self._validate_template_field(data)
130+
131+
def validate_forward_all(self, data):
132+
if data is None:
133+
return False
134+
return data
135+
136+
def validate_http_method(self, http_method):
137+
if http_method not in PUBLIC_WEBHOOK_HTTP_METHODS:
138+
raise serializers.ValidationError(f"Must be one of {PUBLIC_WEBHOOK_HTTP_METHODS}")
139+
return http_method
140+
141+
def validate_integration_filter(self, integration_filter):
142+
if integration_filter:
143+
if type(integration_filter) is not list:
144+
raise serializers.ValidationError(INTEGRATION_FILTER_MESSAGE)
145+
integrations = AlertReceiveChannel.objects.filter(
146+
organization=self.context["request"].auth.organization, public_primary_key__in=integration_filter
147+
)
148+
if len(integrations) != len(integration_filter):
149+
raise serializers.ValidationError(INTEGRATION_FILTER_MESSAGE)
150+
return integration_filter
151+
152+
153+
class WebhookUpdateSerializer(WebhookCreateSerializer):
154+
trigger_type = WebhookTriggerTypeField(required=False)
155+
156+
class Meta(WebhookCreateSerializer.Meta):
157+
extra_kwargs = {
158+
"name": {"required": False, "allow_null": False, "allow_blank": False},
159+
"is_webhook_enabled": {"required": False, "allow_null": False},
160+
"username": {"required": False, "allow_null": True, "allow_blank": False},
161+
"password": {"required": False, "allow_null": True, "allow_blank": False},
162+
"authorization_header": {"required": False, "allow_null": True, "allow_blank": False},
163+
"trigger_template": {"required": False, "allow_null": True, "allow_blank": False},
164+
"headers": {"required": False, "allow_null": True, "allow_blank": False},
165+
"url": {"required": False, "allow_null": False, "allow_blank": False},
166+
"data": {"required": False, "allow_null": True, "allow_blank": False},
167+
"forward_all": {"required": False, "allow_null": False},
168+
"http_method": {"required": False, "allow_null": False, "allow_blank": False},
169+
"integration_filter": {"required": False, "allow_null": True},
170+
}

‎engine/apps/public_api/tests/test_custom_actions.py

+89-105
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,15 @@
33
from rest_framework import status
44
from rest_framework.test import APIClient
55

6-
from apps.alerts.models import CustomButton
6+
from apps.webhooks.models import Webhook
77

88

99
@pytest.mark.django_db
10-
def test_get_custom_actions(
11-
make_organization_and_user_with_token,
12-
make_custom_action,
13-
):
10+
def test_get_custom_actions(make_organization_and_user_with_token, make_custom_webhook):
1411
organization, user, token = make_organization_and_user_with_token()
1512
client = APIClient()
1613

17-
custom_action = make_custom_action(organization=organization)
14+
custom_action = make_custom_webhook(organization=organization)
1815

1916
url = reverse("api-public:actions-list")
2017

@@ -29,12 +26,18 @@ def test_get_custom_actions(
2926
"id": custom_action.public_primary_key,
3027
"name": custom_action.name,
3128
"team_id": None,
32-
"url": custom_action.webhook,
29+
"url": custom_action.url,
3330
"data": custom_action.data,
3431
"user": custom_action.user,
3532
"password": custom_action.password,
3633
"authorization_header": custom_action.authorization_header,
37-
"forward_whole_payload": custom_action.forward_whole_payload,
34+
"forward_whole_payload": custom_action.forward_all,
35+
"is_webhook_enabled": custom_action.is_webhook_enabled,
36+
"trigger_template": custom_action.trigger_template,
37+
"headers": custom_action.headers,
38+
"http_method": custom_action.http_method,
39+
"trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[custom_action.trigger_type],
40+
"integration_filter": custom_action.integration_filter,
3841
}
3942
],
4043
"current_page_number": 1,
@@ -49,13 +52,13 @@ def test_get_custom_actions(
4952
@pytest.mark.django_db
5053
def test_get_custom_actions_filter_by_name(
5154
make_organization_and_user_with_token,
52-
make_custom_action,
55+
make_custom_webhook,
5356
):
5457
organization, user, token = make_organization_and_user_with_token()
5558
client = APIClient()
5659

57-
custom_action = make_custom_action(organization=organization)
58-
make_custom_action(organization=organization)
60+
custom_action = make_custom_webhook(organization=organization)
61+
make_custom_webhook(organization=organization)
5962
url = reverse("api-public:actions-list")
6063

6164
response = client.get(f"{url}?name={custom_action.name}", format="json", HTTP_AUTHORIZATION=f"{token}")
@@ -69,12 +72,18 @@ def test_get_custom_actions_filter_by_name(
6972
"id": custom_action.public_primary_key,
7073
"name": custom_action.name,
7174
"team_id": None,
72-
"url": custom_action.webhook,
75+
"url": custom_action.url,
7376
"data": custom_action.data,
74-
"user": custom_action.user,
77+
"user": custom_action.username,
7578
"password": custom_action.password,
7679
"authorization_header": custom_action.authorization_header,
77-
"forward_whole_payload": custom_action.forward_whole_payload,
80+
"forward_whole_payload": custom_action.forward_all,
81+
"is_webhook_enabled": custom_action.is_webhook_enabled,
82+
"trigger_template": custom_action.trigger_template,
83+
"headers": custom_action.headers,
84+
"http_method": custom_action.http_method,
85+
"trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[custom_action.trigger_type],
86+
"integration_filter": custom_action.integration_filter,
7887
}
7988
],
8089
"current_page_number": 1,
@@ -89,12 +98,12 @@ def test_get_custom_actions_filter_by_name(
8998
@pytest.mark.django_db
9099
def test_get_custom_actions_filter_by_name_empty_result(
91100
make_organization_and_user_with_token,
92-
make_custom_action,
101+
make_custom_webhook,
93102
):
94103
organization, user, token = make_organization_and_user_with_token()
95104
client = APIClient()
96105

97-
make_custom_action(organization=organization)
106+
make_custom_webhook(organization=organization)
98107

99108
url = reverse("api-public:actions-list")
100109

@@ -117,12 +126,12 @@ def test_get_custom_actions_filter_by_name_empty_result(
117126
@pytest.mark.django_db
118127
def test_get_custom_action(
119128
make_organization_and_user_with_token,
120-
make_custom_action,
129+
make_custom_webhook,
121130
):
122131
organization, user, token = make_organization_and_user_with_token()
123132
client = APIClient()
124133

125-
custom_action = make_custom_action(organization=organization)
134+
custom_action = make_custom_webhook(organization=organization)
126135

127136
url = reverse("api-public:actions-detail", kwargs={"pk": custom_action.public_primary_key})
128137

@@ -132,12 +141,18 @@ def test_get_custom_action(
132141
"id": custom_action.public_primary_key,
133142
"name": custom_action.name,
134143
"team_id": None,
135-
"url": custom_action.webhook,
144+
"url": custom_action.url,
136145
"data": custom_action.data,
137-
"user": custom_action.user,
146+
"user": custom_action.username,
138147
"password": custom_action.password,
139148
"authorization_header": custom_action.authorization_header,
140-
"forward_whole_payload": custom_action.forward_whole_payload,
149+
"forward_whole_payload": custom_action.forward_all,
150+
"is_webhook_enabled": custom_action.is_webhook_enabled,
151+
"trigger_template": custom_action.trigger_template,
152+
"headers": custom_action.headers,
153+
"http_method": custom_action.http_method,
154+
"trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[custom_action.trigger_type],
155+
"integration_filter": custom_action.integration_filter,
141156
}
142157

143158
assert response.status_code == status.HTTP_200_OK
@@ -158,18 +173,24 @@ def test_create_custom_action(make_organization_and_user_with_token):
158173

159174
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
160175

161-
custom_action = CustomButton.objects.get(public_primary_key=response.data["id"])
176+
custom_action = Webhook.objects.get(public_primary_key=response.data["id"])
162177

163178
expected_result = {
164179
"id": custom_action.public_primary_key,
165180
"name": custom_action.name,
166181
"team_id": None,
167-
"url": custom_action.webhook,
182+
"url": custom_action.url,
168183
"data": custom_action.data,
169-
"user": custom_action.user,
184+
"user": custom_action.username,
170185
"password": custom_action.password,
171186
"authorization_header": custom_action.authorization_header,
172-
"forward_whole_payload": custom_action.forward_whole_payload,
187+
"forward_whole_payload": custom_action.forward_all,
188+
"is_webhook_enabled": custom_action.is_webhook_enabled,
189+
"trigger_template": custom_action.trigger_template,
190+
"headers": custom_action.headers,
191+
"http_method": custom_action.http_method,
192+
"trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[custom_action.trigger_type],
193+
"integration_filter": custom_action.integration_filter,
173194
}
174195

175196
assert response.status_code == status.HTTP_201_CREATED
@@ -195,18 +216,24 @@ def test_create_custom_action_nested_data(make_organization_and_user_with_token)
195216

196217
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
197218

198-
custom_action = CustomButton.objects.get(public_primary_key=response.data["id"])
219+
custom_action = Webhook.objects.get(public_primary_key=response.data["id"])
199220

200221
expected_result = {
201222
"id": custom_action.public_primary_key,
202223
"name": custom_action.name,
203224
"team_id": None,
204-
"url": custom_action.webhook,
225+
"url": custom_action.url,
205226
"data": custom_action.data,
206-
"user": custom_action.user,
227+
"user": custom_action.username,
207228
"password": custom_action.password,
208229
"authorization_header": custom_action.authorization_header,
209-
"forward_whole_payload": custom_action.forward_whole_payload,
230+
"forward_whole_payload": custom_action.forward_all,
231+
"is_webhook_enabled": custom_action.is_webhook_enabled,
232+
"trigger_template": custom_action.trigger_template,
233+
"headers": custom_action.headers,
234+
"http_method": custom_action.http_method,
235+
"trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[custom_action.trigger_type],
236+
"integration_filter": custom_action.integration_filter,
210237
}
211238

212239
assert response.status_code == status.HTTP_201_CREATED
@@ -232,18 +259,24 @@ def test_create_custom_action_valid_after_render(make_organization_and_user_with
232259

233260
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
234261

235-
custom_action = CustomButton.objects.get(public_primary_key=response.data["id"])
262+
custom_action = Webhook.objects.get(public_primary_key=response.data["id"])
236263

237264
expected_result = {
238265
"id": custom_action.public_primary_key,
239266
"name": custom_action.name,
240267
"team_id": None,
241-
"url": custom_action.webhook,
268+
"url": custom_action.url,
242269
"data": custom_action.data,
243-
"user": custom_action.user,
270+
"user": custom_action.username,
244271
"password": custom_action.password,
245272
"authorization_header": custom_action.authorization_header,
246-
"forward_whole_payload": custom_action.forward_whole_payload,
273+
"forward_whole_payload": custom_action.forward_all,
274+
"is_webhook_enabled": custom_action.is_webhook_enabled,
275+
"trigger_template": custom_action.trigger_template,
276+
"headers": custom_action.headers,
277+
"http_method": custom_action.http_method,
278+
"trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[custom_action.trigger_type],
279+
"integration_filter": custom_action.integration_filter,
247280
}
248281

249282
assert response.status_code == status.HTTP_201_CREATED
@@ -269,94 +302,39 @@ def test_create_custom_action_valid_after_render_use_all_data(make_organization_
269302

270303
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
271304

272-
custom_action = CustomButton.objects.get(public_primary_key=response.data["id"])
305+
custom_action = Webhook.objects.get(public_primary_key=response.data["id"])
273306

274307
expected_result = {
275308
"id": custom_action.public_primary_key,
276309
"name": custom_action.name,
277310
"team_id": None,
278-
"url": custom_action.webhook,
311+
"url": custom_action.url,
279312
"data": custom_action.data,
280-
"user": custom_action.user,
313+
"user": custom_action.username,
281314
"password": custom_action.password,
282315
"authorization_header": custom_action.authorization_header,
283-
"forward_whole_payload": custom_action.forward_whole_payload,
316+
"forward_whole_payload": custom_action.forward_all,
317+
"is_webhook_enabled": custom_action.is_webhook_enabled,
318+
"trigger_template": custom_action.trigger_template,
319+
"headers": custom_action.headers,
320+
"http_method": custom_action.http_method,
321+
"trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[custom_action.trigger_type],
322+
"integration_filter": custom_action.integration_filter,
284323
}
285324

286325
assert response.status_code == status.HTTP_201_CREATED
287326
assert response.json() == expected_result
288327

289328

290-
@pytest.mark.django_db
291-
def test_create_custom_action_invalid_data(
292-
make_organization_and_user_with_token,
293-
):
294-
organization, user, token = make_organization_and_user_with_token()
295-
client = APIClient()
296-
297-
url = reverse("api-public:actions-list")
298-
299-
data = {
300-
"name": "Test outgoing webhook",
301-
"url": "invalid_url",
302-
}
303-
304-
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
305-
306-
assert response.status_code == status.HTTP_400_BAD_REQUEST
307-
assert response.data["url"][0] == "URL is incorrect"
308-
309-
data = {
310-
"name": "Test outgoing webhook",
311-
}
312-
313-
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
314-
315-
assert response.status_code == status.HTTP_400_BAD_REQUEST
316-
assert response.data["url"][0] == "This field is required."
317-
318-
data = {
319-
"url": "https://example.com",
320-
}
321-
322-
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
323-
324-
assert response.status_code == status.HTTP_400_BAD_REQUEST
325-
assert response.data["name"][0] == "This field is required."
326-
327-
data = {
328-
"name": "Test outgoing webhook",
329-
"url": "https://example.com",
330-
"data": "invalid_json",
331-
}
332-
333-
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
334-
335-
assert response.status_code == status.HTTP_400_BAD_REQUEST
336-
assert response.data["data"][0] == "Data has incorrect format"
337-
338-
data = {
339-
"name": "Test outgoing webhook",
340-
"url": "https://example.com",
341-
# This would need a `| tojson` or some double quotes around it to pass validation.
342-
"data": "{{ alert_payload.name }}",
343-
}
344-
345-
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
346-
347-
assert response.status_code == status.HTTP_400_BAD_REQUEST
348-
assert response.data["data"][0] == "Data has incorrect format"
349-
350-
351329
@pytest.mark.django_db
352330
def test_update_custom_action(
353331
make_organization_and_user_with_token,
354-
make_custom_action,
332+
make_custom_webhook,
355333
):
356334
organization, user, token = make_organization_and_user_with_token()
357335
client = APIClient()
358336

359-
custom_action = make_custom_action(organization=organization)
337+
custom_action = make_custom_webhook(organization=organization)
360338

361339
url = reverse("api-public:actions-detail", kwargs={"pk": custom_action.public_primary_key})
362340

@@ -372,12 +350,18 @@ def test_update_custom_action(
372350
"id": custom_action.public_primary_key,
373351
"name": data["name"],
374352
"team_id": None,
375-
"url": custom_action.webhook,
353+
"url": custom_action.url,
376354
"data": custom_action.data,
377-
"user": custom_action.user,
355+
"user": custom_action.username,
378356
"password": custom_action.password,
379357
"authorization_header": custom_action.authorization_header,
380-
"forward_whole_payload": custom_action.forward_whole_payload,
358+
"forward_whole_payload": custom_action.forward_all,
359+
"is_webhook_enabled": custom_action.is_webhook_enabled,
360+
"trigger_template": custom_action.trigger_template,
361+
"headers": custom_action.headers,
362+
"http_method": custom_action.http_method,
363+
"trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[custom_action.trigger_type],
364+
"integration_filter": custom_action.integration_filter,
381365
}
382366

383367
assert response.status_code == status.HTTP_200_OK
@@ -389,12 +373,12 @@ def test_update_custom_action(
389373
@pytest.mark.django_db
390374
def test_delete_custom_action(
391375
make_organization_and_user_with_token,
392-
make_custom_action,
376+
make_custom_webhook,
393377
):
394378
organization, user, token = make_organization_and_user_with_token()
395379
client = APIClient()
396380

397-
custom_action = make_custom_action(organization=organization)
381+
custom_action = make_custom_webhook(organization=organization)
398382
url = reverse("api-public:actions-detail", kwargs={"pk": custom_action.public_primary_key})
399383

400384
assert custom_action.deleted_at is None
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
import json
2+
3+
import pytest
4+
from django.urls import reverse
5+
from rest_framework import status
6+
from rest_framework.test import APIClient
7+
8+
from apps.webhooks.models import Webhook
9+
10+
11+
def _get_expected_result(webhook):
12+
return {
13+
"id": webhook.public_primary_key,
14+
"name": webhook.name,
15+
"team": webhook.team,
16+
"url": webhook.url,
17+
"data": webhook.data,
18+
"username": webhook.username,
19+
"password": webhook.password,
20+
"authorization_header": webhook.authorization_header,
21+
"forward_all": webhook.forward_all,
22+
"is_webhook_enabled": webhook.is_webhook_enabled,
23+
"trigger_template": webhook.trigger_template,
24+
"headers": webhook.headers,
25+
"http_method": webhook.http_method,
26+
"trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[webhook.trigger_type],
27+
"integration_filter": webhook.integration_filter,
28+
}
29+
30+
31+
@pytest.mark.django_db
32+
def test_get_webhooks(make_organization_and_user_with_token, make_custom_webhook):
33+
organization, user, token = make_organization_and_user_with_token()
34+
client = APIClient()
35+
36+
webhook = make_custom_webhook(organization=organization)
37+
38+
url = reverse("api-public:webhooks-list")
39+
40+
response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}")
41+
42+
expected_payload = {
43+
"count": 1,
44+
"next": None,
45+
"previous": None,
46+
"results": [_get_expected_result(webhook)],
47+
"current_page_number": 1,
48+
"page_size": 50,
49+
"total_pages": 1,
50+
}
51+
52+
assert response.status_code == status.HTTP_200_OK
53+
assert response.data == expected_payload
54+
55+
56+
@pytest.mark.django_db
57+
def test_get_webhooks_filter_by_name(
58+
make_organization_and_user_with_token,
59+
make_custom_webhook,
60+
):
61+
organization, user, token = make_organization_and_user_with_token()
62+
client = APIClient()
63+
64+
webhook = make_custom_webhook(organization=organization)
65+
make_custom_webhook(organization=organization)
66+
url = reverse("api-public:webhooks-list")
67+
68+
response = client.get(f"{url}?name={webhook.name}", format="json", HTTP_AUTHORIZATION=f"{token}")
69+
70+
expected_payload = {
71+
"count": 1,
72+
"next": None,
73+
"previous": None,
74+
"results": [_get_expected_result(webhook)],
75+
"current_page_number": 1,
76+
"page_size": 50,
77+
"total_pages": 1,
78+
}
79+
80+
assert response.status_code == status.HTTP_200_OK
81+
assert response.data == expected_payload
82+
83+
84+
@pytest.mark.django_db
85+
def test_get_webhooks_filter_by_name_empty_result(
86+
make_organization_and_user_with_token,
87+
make_custom_webhook,
88+
):
89+
organization, user, token = make_organization_and_user_with_token()
90+
client = APIClient()
91+
92+
make_custom_webhook(organization=organization)
93+
94+
url = reverse("api-public:webhooks-list")
95+
96+
response = client.get(f"{url}?name=NonExistentName", format="json", HTTP_AUTHORIZATION=f"{token}")
97+
98+
expected_payload = {
99+
"count": 0,
100+
"next": None,
101+
"previous": None,
102+
"results": [],
103+
"current_page_number": 1,
104+
"page_size": 50,
105+
"total_pages": 1,
106+
}
107+
108+
assert response.status_code == status.HTTP_200_OK
109+
assert response.data == expected_payload
110+
111+
112+
@pytest.mark.django_db
113+
def test_get_webhook(
114+
make_organization_and_user_with_token,
115+
make_custom_webhook,
116+
):
117+
organization, user, token = make_organization_and_user_with_token()
118+
client = APIClient()
119+
120+
webhook = make_custom_webhook(organization=organization)
121+
122+
url = reverse("api-public:webhooks-detail", kwargs={"pk": webhook.public_primary_key})
123+
124+
response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}")
125+
126+
expected_payload = _get_expected_result(webhook)
127+
128+
assert response.status_code == status.HTTP_200_OK
129+
assert response.data == expected_payload
130+
131+
132+
@pytest.mark.django_db
133+
def test_create_webhook(make_organization_and_user_with_token):
134+
organization, user, token = make_organization_and_user_with_token()
135+
client = APIClient()
136+
137+
url = reverse("api-public:webhooks-list")
138+
139+
data = {}
140+
141+
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
142+
assert response.status_code == status.HTTP_400_BAD_REQUEST
143+
data["name"] = "Test outgoing webhook"
144+
145+
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
146+
assert response.status_code == status.HTTP_400_BAD_REQUEST
147+
data["url"] = "https://example.com"
148+
149+
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
150+
assert response.status_code == status.HTTP_400_BAD_REQUEST
151+
data["trigger_type"] = "escalation"
152+
153+
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
154+
assert response.status_code == status.HTTP_400_BAD_REQUEST
155+
data["http_method"] = "POST"
156+
157+
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
158+
assert response.status_code == status.HTTP_201_CREATED
159+
webhook = Webhook.objects.get(public_primary_key=response.data["id"])
160+
161+
expected_result = _get_expected_result(webhook)
162+
163+
assert response.data == expected_result
164+
165+
166+
@pytest.mark.django_db
167+
def test_create_webhook_nested_data(make_organization_and_user_with_token):
168+
organization, user, token = make_organization_and_user_with_token()
169+
client = APIClient()
170+
171+
url = reverse("api-public:webhooks-list")
172+
173+
data = {
174+
"name": "Test outgoing webhook with nested data",
175+
"url": "https://example.com",
176+
"data": '{"nested_item": "{{ alert_payload.foo.bar | to_json }}"}',
177+
"http_method": "POST",
178+
"trigger_type": "acknowledge",
179+
}
180+
181+
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
182+
assert response.status_code == status.HTTP_400_BAD_REQUEST
183+
data["data"] = '{"nested_item": "{{ alert_payload.foo.bar | tojson() }}"}'
184+
185+
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
186+
webhook = Webhook.objects.get(public_primary_key=response.data["id"])
187+
188+
expected_result = _get_expected_result(webhook)
189+
190+
assert response.status_code == status.HTTP_201_CREATED
191+
assert response.json() == expected_result
192+
193+
194+
@pytest.mark.django_db
195+
def test_update_webhook(
196+
make_organization_and_user_with_token,
197+
make_custom_webhook,
198+
):
199+
organization, user, token = make_organization_and_user_with_token()
200+
client = APIClient()
201+
202+
webhook = make_custom_webhook(organization=organization)
203+
url = reverse("api-public:webhooks-detail", kwargs={"pk": webhook.public_primary_key})
204+
data = {
205+
"name": "RENAMED",
206+
}
207+
assert webhook.name != data["name"]
208+
209+
response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
210+
211+
expected_result = _get_expected_result(webhook)
212+
expected_result["name"] = data["name"]
213+
214+
assert response.status_code == status.HTTP_200_OK
215+
webhook.refresh_from_db()
216+
assert webhook.name == expected_result["name"]
217+
assert response.data == expected_result
218+
219+
220+
@pytest.mark.django_db
221+
def test_delete_webhook(
222+
make_organization_and_user_with_token,
223+
make_custom_webhook,
224+
):
225+
organization, user, token = make_organization_and_user_with_token()
226+
client = APIClient()
227+
228+
webhook = make_custom_webhook(organization=organization)
229+
url = reverse("api-public:webhooks-detail", kwargs={"pk": webhook.public_primary_key})
230+
231+
assert webhook.deleted_at is None
232+
233+
response = client.delete(url, format="json", HTTP_AUTHORIZATION=f"{token}")
234+
assert response.status_code == status.HTTP_204_NO_CONTENT
235+
236+
webhook.refresh_from_db()
237+
assert webhook.deleted_at is not None
238+
239+
response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}")
240+
241+
assert response.status_code == status.HTTP_404_NOT_FOUND
242+
assert response.data["detail"] == "Not found."
243+
244+
245+
@pytest.mark.django_db
246+
def test_get_webhook_responses(
247+
make_organization_and_user_with_token,
248+
make_custom_webhook,
249+
make_webhook_response,
250+
):
251+
organization, user, token = make_organization_and_user_with_token()
252+
client = APIClient()
253+
254+
webhook = make_custom_webhook(organization=organization)
255+
webhook.refresh_from_db()
256+
257+
response_count = 20
258+
for i in range(0, response_count):
259+
make_webhook_response(
260+
webhook=webhook,
261+
trigger_type=webhook.trigger_type,
262+
status_code=200,
263+
content=json.dumps({"id": "third-party-id"}),
264+
event_data=json.dumps({"test": "abc"}),
265+
)
266+
267+
url = reverse("api-public:webhooks-responses", kwargs={"pk": webhook.public_primary_key})
268+
response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}")
269+
webhook_response = response.data["results"][0]
270+
assert webhook_response["status_code"] == 200
271+
assert webhook_response["content"] == '{"id": "third-party-id"}'
272+
assert webhook_response["event_data"] == '{"test": "abc"}'
273+
assert response.data["count"] == 20
274+
assert response.status_code == status.HTTP_200_OK
275+
276+
277+
@pytest.mark.django_db
278+
def test_webhook_validate_integration_filters(
279+
make_organization_and_user_with_token,
280+
make_custom_webhook,
281+
make_alert_receive_channel,
282+
):
283+
organization, user, token = make_organization_and_user_with_token()
284+
alert_receive_channel = make_alert_receive_channel(organization)
285+
webhook = make_custom_webhook(organization=organization)
286+
url = reverse("api-public:webhooks-detail", kwargs={"pk": webhook.public_primary_key})
287+
data = {"integration_filter": alert_receive_channel.public_primary_key}
288+
289+
client = APIClient()
290+
response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
291+
assert response.status_code == 400
292+
293+
data["integration_filter"] = ["abc"]
294+
response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
295+
assert response.status_code == 400
296+
297+
data["integration_filter"] = [alert_receive_channel.public_primary_key, alert_receive_channel.public_primary_key]
298+
response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
299+
assert response.status_code == 400
300+
301+
data["integration_filter"] = [alert_receive_channel.public_primary_key]
302+
response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
303+
webhook.refresh_from_db()
304+
assert response.status_code == 200
305+
assert response.data["integration_filter"] == data["integration_filter"]
306+
assert webhook.integration_filter == data["integration_filter"]
307+
308+
data["integration_filter"] = []
309+
response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
310+
webhook.refresh_from_db()
311+
assert response.status_code == 200
312+
assert response.data["integration_filter"] == data["integration_filter"]
313+
assert webhook.integration_filter == data["integration_filter"]
314+
315+
data["integration_filter"] = None
316+
response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
317+
webhook.refresh_from_db()
318+
assert response.status_code == 200
319+
assert response.data["integration_filter"] == data["integration_filter"]
320+
assert webhook.integration_filter == data["integration_filter"]

‎engine/apps/public_api/urls.py

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
router.register(r"on_call_shifts", views.CustomOnCallShiftView, basename="on_call_shifts")
2727
router.register(r"teams", views.TeamView, basename="teams")
2828
router.register(r"shift_swaps", views.ShiftSwapViewSet, basename="shift_swap")
29+
router.register(r"webhooks", views.WebhooksView, basename="webhooks")
2930

3031

3132
urlpatterns = [

‎engine/apps/public_api/views/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@
1717
from .teams import TeamView # noqa: F401
1818
from .user_groups import UserGroupView # noqa: F401
1919
from .users import UserView # noqa: F401
20+
from .webhooks import WebhooksView # noqa: F401

‎engine/apps/public_api/views/action.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22
from rest_framework.permissions import IsAuthenticated
33
from rest_framework.viewsets import ModelViewSet
44

5-
from apps.alerts.models import CustomButton
5+
from apps.api.serializers.webhook import WebhookSerializer
66
from apps.auth_token.auth import ApiTokenAuthentication
77
from apps.public_api.serializers.action import ActionCreateSerializer, ActionUpdateSerializer
88
from apps.public_api.throttlers.user_throttle import UserThrottle
9+
from apps.webhooks.models import Webhook
910
from common.api_helpers.filters import ByTeamFilter
1011
from common.api_helpers.mixins import PublicPrimaryKeyMixin, RateLimitHeadersMixin, UpdateSerializerMixin
1112
from common.api_helpers.paginators import FiftyPageSizePaginator
@@ -18,7 +19,7 @@ class ActionView(RateLimitHeadersMixin, PublicPrimaryKeyMixin, UpdateSerializerM
1819
pagination_class = FiftyPageSizePaginator
1920
throttle_classes = [UserThrottle]
2021

21-
model = CustomButton
22+
model = WebhookSerializer
2223
serializer_class = ActionCreateSerializer
2324
update_serializer_class = ActionUpdateSerializer
2425

@@ -27,7 +28,7 @@ class ActionView(RateLimitHeadersMixin, PublicPrimaryKeyMixin, UpdateSerializerM
2728

2829
def get_queryset(self):
2930
action_name = self.request.query_params.get("name", None)
30-
queryset = CustomButton.objects.filter(organization=self.request.auth.organization)
31+
queryset = Webhook.objects.filter(organization=self.request.auth.organization)
3132

3233
if action_name:
3334
queryset = queryset.filter(name=action_name)
+94
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
from django_filters import rest_framework as filters
2+
from rest_framework.decorators import action
3+
from rest_framework.exceptions import NotFound
4+
from rest_framework.permissions import IsAuthenticated
5+
from rest_framework.response import Response
6+
from rest_framework.viewsets import ModelViewSet
7+
8+
from apps.auth_token.auth import ApiTokenAuthentication
9+
from apps.public_api.serializers.webhooks import (
10+
WebhookCreateSerializer,
11+
WebhookResponseSerializer,
12+
WebhookUpdateSerializer,
13+
)
14+
from apps.public_api.throttlers import UserThrottle
15+
from apps.webhooks.models import Webhook, WebhookResponse
16+
from common.api_helpers.filters import ByTeamFilter
17+
from common.api_helpers.mixins import RateLimitHeadersMixin, UpdateSerializerMixin
18+
from common.api_helpers.paginators import FiftyPageSizePaginator
19+
from common.insight_log import EntityEvent, write_resource_insight_log
20+
21+
22+
class WebhooksView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelViewSet):
23+
authentication_classes = (ApiTokenAuthentication,)
24+
permission_classes = (IsAuthenticated,)
25+
pagination_class = FiftyPageSizePaginator
26+
throttle_classes = [UserThrottle]
27+
28+
model = Webhook
29+
serializer_class = WebhookCreateSerializer
30+
update_serializer_class = WebhookUpdateSerializer
31+
32+
filter_backends = (filters.DjangoFilterBackend,)
33+
filterset_class = ByTeamFilter
34+
35+
def get_queryset(self):
36+
webhook_name = self.request.query_params.get("name", None)
37+
queryset = Webhook.objects.filter(organization=self.request.auth.organization)
38+
39+
if webhook_name:
40+
queryset = queryset.filter(name=webhook_name)
41+
42+
return queryset.order_by("id")
43+
44+
def get_object(self):
45+
public_primary_key = self.kwargs["pk"]
46+
47+
try:
48+
return Webhook.objects.filter(organization=self.request.auth.organization).get(
49+
public_primary_key=public_primary_key
50+
)
51+
except Webhook.DoesNotExist:
52+
raise NotFound
53+
54+
def perform_create(self, serializer):
55+
serializer.save()
56+
write_resource_insight_log(
57+
instance=serializer.instance,
58+
author=self.request.user,
59+
event=EntityEvent.CREATED,
60+
)
61+
62+
def perform_update(self, serializer):
63+
prev_state = serializer.instance.insight_logs_serialized
64+
serializer.save()
65+
new_state = serializer.instance.insight_logs_serialized
66+
write_resource_insight_log(
67+
instance=serializer.instance,
68+
author=self.request.user,
69+
event=EntityEvent.UPDATED,
70+
prev_state=prev_state,
71+
new_state=new_state,
72+
)
73+
74+
def perform_destroy(self, instance):
75+
write_resource_insight_log(
76+
instance=instance,
77+
author=self.request.user,
78+
event=EntityEvent.DELETED,
79+
)
80+
instance.delete()
81+
82+
@action(methods=["get"], detail=True)
83+
def responses(self, request, pk):
84+
webhook = self.get_object()
85+
queryset = WebhookResponse.objects.filter(webhook_id=webhook.id, trigger_type=webhook.trigger_type).order_by(
86+
"-timestamp"
87+
)
88+
page = self.paginate_queryset(queryset)
89+
if page is not None:
90+
response_serializer = WebhookResponseSerializer(page, many=True)
91+
return self.get_paginated_response(response_serializer.data)
92+
93+
response_serializer = WebhookResponseSerializer(queryset, many=True)
94+
return Response(response_serializer.data)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 3.2.20 on 2023-08-14 22:50
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('webhooks', '0009_alter_webhook_authorization_header'),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name='webhook',
15+
name='trigger_type',
16+
field=models.IntegerField(choices=[(0, 'Escalation step'), (1, 'Alert Group Created'), (2, 'Acknowledged'), (3, 'Resolved'), (4, 'Silenced'), (5, 'Unsilenced'), (6, 'Unresolved'), (7, 'Unacknowledged')], default=0, null=True),
17+
),
18+
]

‎engine/apps/webhooks/models/webhook.py

+20-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import json
2+
import logging
23
import typing
34
from json import JSONDecodeError
45

56
import requests
7+
from celery.utils.log import get_task_logger
68
from django.conf import settings
79
from django.core.validators import MinLengthValidator
810
from django.db import models
@@ -30,6 +32,10 @@
3032
from apps.alerts.models import EscalationPolicy
3133

3234
WEBHOOK_FIELD_PLACEHOLDER = "****************"
35+
PUBLIC_WEBHOOK_HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
36+
37+
logger = get_task_logger(__name__)
38+
logger.setLevel(logging.DEBUG)
3339

3440

3541
def generate_public_primary_key_for_webhook():
@@ -88,6 +94,19 @@ class Webhook(models.Model):
8894
(TRIGGER_UNACKNOWLEDGE, "Unacknowledged"),
8995
)
9096

97+
PUBLIC_TRIGGER_TYPES_MAP = {
98+
TRIGGER_ESCALATION_STEP: "escalation",
99+
TRIGGER_ALERT_GROUP_CREATED: "alert group created",
100+
TRIGGER_ACKNOWLEDGE: "acknowledge",
101+
TRIGGER_RESOLVE: "resolve",
102+
TRIGGER_SILENCE: "silence",
103+
TRIGGER_UNSILENCE: "unsilence",
104+
TRIGGER_UNRESOLVE: "unresolve",
105+
TRIGGER_UNACKNOWLEDGE: "unacknowledge",
106+
}
107+
108+
PUBLIC_ALL_TRIGGER_TYPES = [i for i in PUBLIC_TRIGGER_TYPES_MAP.values()]
109+
91110
public_primary_key = models.CharField(
92111
max_length=20,
93112
validators=[MinLengthValidator(settings.PUBLIC_PRIMARY_KEY_MIN_LENGTH + 1)],
@@ -119,7 +138,7 @@ class Webhook(models.Model):
119138
data = models.TextField(null=True, default=None)
120139
forward_all = models.BooleanField(default=True)
121140
http_method = models.CharField(max_length=32, default="POST")
122-
trigger_type = models.IntegerField(choices=TRIGGER_TYPES, default=None, null=True)
141+
trigger_type = models.IntegerField(choices=TRIGGER_TYPES, default=TRIGGER_ESCALATION_STEP, null=True)
123142
is_webhook_enabled = models.BooleanField(null=True, default=True)
124143
integration_filter = models.JSONField(default=None, null=True, blank=True)
125144
is_legacy = models.BooleanField(null=True, default=False)

0 commit comments

Comments
 (0)
Please sign in to comment.