Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add webhook presets #2996

Merged
merged 25 commits into from
Sep 27, 2023
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
572b336
WIP add presets for webhooks
mderynck Sep 7, 2023
8dcb84b
WIP add presets for webhooks
mderynck Sep 12, 2023
084f9e0
Fix tests
mderynck Sep 12, 2023
427b222
Add webhook preset selection page
mderynck Sep 13, 2023
f90cb9b
Remove unused permission
mderynck Sep 13, 2023
5f52ec9
Update docs
mderynck Sep 14, 2023
bbc4521
Merge branch 'dev' into mderynck/webhook-presets
mderynck Sep 14, 2023
9067aea
Add/fix tests
mderynck Sep 15, 2023
f409d31
Update CHANGELOG, only show search if number of webhooks don't fit on…
mderynck Sep 15, 2023
ba39c3d
More validation, block presets in public API, improve field labelling…
mderynck Sep 19, 2023
a7c6bc3
Merge dev
mderynck Sep 19, 2023
b0a4db7
Merge branch 'dev' into mderynck/webhook-presets
mderynck Sep 19, 2023
5c04071
Fix tests
mderynck Sep 19, 2023
4d9a939
Fix tests
mderynck Sep 19, 2023
45f0314
Use abstract class for presets, add hook to change parameters at runt…
mderynck Sep 20, 2023
4afad1a
Use abstract class for presets, add hook to change parameters at runt…
mderynck Sep 20, 2023
e3781ce
Add tests for runtime overrides
mderynck Sep 20, 2023
6373fe2
Fix migration
mderynck Sep 20, 2023
a2faf4a
Fix test
mderynck Sep 20, 2023
33ecede
Update docs/sources/outgoing-webhooks/_index.md
mderynck Sep 26, 2023
1f99871
Add extension point for webhook icons
mderynck Sep 26, 2023
40a24bd
Merge branch 'mderynck/webhook-presets' of github.com:grafana/oncall …
mderynck Sep 26, 2023
d9a5dda
Fix import for test
mderynck Sep 26, 2023
e5433a7
Fallback to webhook icon if other not found
mderynck Sep 26, 2023
684fe50
Fix import to include additional preset icons
mderynck Sep 27, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Added

- Presets for webhooks @mderynck ([#2996](https://github.com/grafana/oncall/pull/2996))

### Fixed

- Fix Slack access token length issue by @toolchainX ([#3016](https://github.com/grafana/oncall/pull/3016))
Expand Down
6 changes: 4 additions & 2 deletions docs/sources/outgoing-webhooks/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ Jinja2 templates to customize the request being sent.
## Creating an outgoing webhook

To create an outgoing webhook navigate to **Outgoing Webhooks** and click **+ Create**. On this screen outgoing
webhooks can be viewed, edited and deleted. To create the outgoing webhook populate the required fields and
click **Create Webhook**
webhooks can be viewed, edited and deleted. To create the outgoing webhook click **Create Webhook** and then
select a preset based on what you want to do. A simple webhook will POST alert group data as a selectable escalation
step to the specified url. If you require more customization use the advanced webhook which provides all of the
field described below.
mderynck marked this conversation as resolved.
Show resolved Hide resolved

### Outgoing webhook fields

Expand Down
81 changes: 74 additions & 7 deletions engine/apps/api/serializers/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
from rest_framework.validators import UniqueTogetherValidator

from apps.webhooks.models import Webhook, WebhookResponse
from apps.webhooks.models.webhook import WEBHOOK_FIELD_PLACEHOLDER
from apps.webhooks.models.webhook import PUBLIC_WEBHOOK_HTTP_METHODS, WEBHOOK_FIELD_PLACEHOLDER
from apps.webhooks.presets.preset_options import WebhookPresetOptions
from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField
from common.api_helpers.utils import CurrentOrganizationDefault, CurrentTeamDefault, CurrentUserDefault
from common.jinja_templater import apply_jinja_template
Expand All @@ -31,9 +32,9 @@ class WebhookSerializer(serializers.ModelSerializer):
organization = serializers.HiddenField(default=CurrentOrganizationDefault())
team = TeamPrimaryKeyRelatedField(allow_null=True, default=CurrentTeamDefault())
user = serializers.HiddenField(default=CurrentUserDefault())
trigger_type = serializers.CharField(required=True)
forward_all = serializers.BooleanField(allow_null=True, required=False)
last_response_log = serializers.SerializerMethodField()
trigger_type = serializers.CharField(allow_null=True)
trigger_type_name = serializers.SerializerMethodField()

class Meta:
Expand All @@ -59,11 +60,8 @@ class Meta:
"trigger_type_name",
"last_response_log",
"integration_filter",
"preset",
]
extra_kwargs = {
"name": {"required": True, "allow_null": False, "allow_blank": False},
mderynck marked this conversation as resolved.
Show resolved Hide resolved
"url": {"required": True, "allow_null": False, "allow_blank": False},
}

validators = [UniqueTogetherValidator(queryset=Webhook.objects.all(), fields=["name", "organization"])]

Expand All @@ -78,6 +76,16 @@ def to_representation(self, instance):
def to_internal_value(self, data):
webhook = self.instance

# Some fields are conditionally required, add none values for missing required fields
if webhook and webhook.preset and "preset" not in data:
data["preset"] = webhook.preset
for key in ["url", "http_method", "trigger_type"]:
if key not in data:
if self.instance:
data[key] = getattr(self.instance, key)
else:
data[key] = None

# If webhook is being copied instance won't exist to copy values from
if not webhook and "id" in data:
webhook = Webhook.objects.get(
Expand Down Expand Up @@ -111,10 +119,29 @@ def validate_headers(self, headers):
return self._validate_template_field(headers)

def validate_url(self, url):
if self.is_field_ignored("url"):
return url

if not url:
return None
raise serializers.ValidationError(detail="This field is required.")
return self._validate_template_field(url)

def validate_http_method(self, http_method):
if self.is_field_ignored("http_method"):
return http_method

if http_method not in PUBLIC_WEBHOOK_HTTP_METHODS:
raise serializers.ValidationError(detail=f"This field must be one of {PUBLIC_WEBHOOK_HTTP_METHODS}.")
return http_method

def validate_trigger_type(self, trigger_type):
if self.is_field_ignored("trigger_type"):
return trigger_type

if not trigger_type or int(trigger_type) not in Webhook.ALL_TRIGGER_TYPES:
raise serializers.ValidationError(detail="This field is required.")
return trigger_type

def validate_data(self, data):
if not data:
return None
Expand All @@ -125,6 +152,28 @@ def validate_forward_all(self, data):
return False
return data

def validate_preset(self, preset):
if self.instance and self.instance.preset != preset:
raise serializers.ValidationError(detail="This field once set cannot be modified.")

if preset:
if preset not in WebhookPresetOptions.WEBHOOK_PRESET_METADATA:
raise serializers.ValidationError(detail=f"{preset} is not a valid preset id.")

preset_metadata = WebhookPresetOptions.WEBHOOK_PRESET_METADATA[preset]
ignored_fields = preset_metadata["ignored_fields"]
for ignored in ignored_fields:
if ignored in self.initial_data:
if self.instance:
if self.initial_data[ignored] != getattr(self.instance, ignored):
raise serializers.ValidationError(
detail=f"{ignored} is controlled by preset, cannot update"
)
elif self.initial_data[ignored] is not None:
raise serializers.ValidationError(detail=f"{ignored} is controlled by preset, cannot create")

return preset

def get_last_response_log(self, obj):
return WebhookResponseSerializer(obj.responses.all().last()).data

Expand All @@ -133,3 +182,21 @@ def get_trigger_type_name(self, obj):
if obj.trigger_type is not None:
trigger_type_name = Webhook.TRIGGER_TYPES[int(obj.trigger_type)][1]
return trigger_type_name

def is_field_ignored(self, field_name):
if self.instance:
if not self.instance.preset:
return False
elif "preset" not in self.initial_data:
return False

preset_id = self.instance.preset if self.instance else self.initial_data["preset"]
if preset_id:
if preset_id not in WebhookPresetOptions.WEBHOOK_PRESET_METADATA:
raise serializers.ValidationError(detail=f"unknown preset {preset_id} referenced")

preset_metadata = WebhookPresetOptions.WEBHOOK_PRESET_METADATA[preset_id]
ignored_fields = preset_metadata["ignored_fields"]
if field_name not in ignored_fields:
return False
return True
163 changes: 163 additions & 0 deletions engine/apps/api/tests/test_webhook_presets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import pytest
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient

from apps.webhooks.models import Webhook
from apps.webhooks.models.webhook import WEBHOOK_FIELD_PLACEHOLDER
from conftest import (
TEST_WEBHOOK_LOGO,
TEST_WEBHOOK_PRESET_DESCRIPTION,
TEST_WEBHOOK_PRESET_ID,
TEST_WEBHOOK_PRESET_IGNORED_FIELDS,
TEST_WEBHOOK_PRESET_NAME,
TEST_WEBHOOK_PRESET_URL,
)


@pytest.mark.django_db
def test_get_webhook_preset_options(
make_organization_and_user_with_plugin_token, webhook_preset_api_setup, make_user_auth_headers
):
organization, user, token = make_organization_and_user_with_plugin_token()
client = APIClient()
url = reverse("api-internal:webhooks-preset-options")

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

assert response.status_code == status.HTTP_200_OK
assert response.data[0]["id"] == TEST_WEBHOOK_PRESET_ID
assert response.data[0]["name"] == TEST_WEBHOOK_PRESET_NAME
assert response.data[0]["logo"] == TEST_WEBHOOK_LOGO
assert response.data[0]["description"] == TEST_WEBHOOK_PRESET_DESCRIPTION
assert response.data[0]["ignored_fields"] == TEST_WEBHOOK_PRESET_IGNORED_FIELDS


@pytest.mark.django_db
def test_create_webhook_from_preset(
make_organization_and_user_with_plugin_token, webhook_preset_api_setup, make_user_auth_headers
):
organization, user, token = make_organization_and_user_with_plugin_token()
client = APIClient()
url = reverse("api-internal:webhooks-list")

data = {
"name": "the_webhook",
"trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED,
"team": None,
"password": "secret_password",
"authorization_header": "auth 1234",
"preset": TEST_WEBHOOK_PRESET_ID,
}
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
webhook = Webhook.objects.get(public_primary_key=response.data["id"])

expected_response = data | {
"id": webhook.public_primary_key,
"url": TEST_WEBHOOK_PRESET_URL,
"data": organization.org_title,
"username": None,
"password": WEBHOOK_FIELD_PLACEHOLDER,
"authorization_header": WEBHOOK_FIELD_PLACEHOLDER,
"forward_all": True,
"headers": None,
"http_method": "GET",
"integration_filter": None,
"is_webhook_enabled": True,
"is_legacy": False,
"last_response_log": {
"request_data": "",
"request_headers": "",
"timestamp": None,
"content": "",
"status_code": None,
"request_trigger": "",
"url": "",
"event_data": "",
},
"trigger_template": None,
"trigger_type": str(data["trigger_type"]),
"trigger_type_name": "Alert Group Created",
}

assert response.status_code == status.HTTP_201_CREATED
assert response.json() == expected_response
assert webhook.password == data["password"]
assert webhook.authorization_header == data["authorization_header"]


@pytest.mark.django_db
def test_invalid_create_webhook_with_preset(
make_organization_and_user_with_plugin_token, webhook_preset_api_setup, make_user_auth_headers
):
organization, user, token = make_organization_and_user_with_plugin_token()
client = APIClient()
url = reverse("api-internal:webhooks-list")

data = {
"name": "the_webhook",
"trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED,
"url": "https://test12345.com",
"preset": TEST_WEBHOOK_PRESET_ID,
}
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["preset"][0] == "url is controlled by preset, cannot create"


@pytest.mark.django_db
def test_update_webhook_from_preset(
make_organization_and_user_with_plugin_token, webhook_preset_api_setup, make_user_auth_headers, make_custom_webhook
):
organization, user, token = make_organization_and_user_with_plugin_token()
webhook = make_custom_webhook(
name="the_webhook",
organization=organization,
trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED,
preset=TEST_WEBHOOK_PRESET_ID,
)

client = APIClient()
url = reverse("api-internal:webhooks-detail", kwargs={"pk": webhook.public_primary_key})

data = {
"name": "the_webhook 2",
}
response = client.put(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.json()["name"] == data["name"]

webhook.refresh_from_db()
assert webhook.name == data["name"]
assert webhook.url == TEST_WEBHOOK_PRESET_URL
assert webhook.http_method == "GET"
assert webhook.data == organization.org_title


@pytest.mark.django_db
def test_invalid_update_webhook_from_preset(
make_organization_and_user_with_plugin_token, webhook_preset_api_setup, make_user_auth_headers, make_custom_webhook
):
organization, user, token = make_organization_and_user_with_plugin_token()
webhook = make_custom_webhook(
name="the_webhook",
organization=organization,
trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED,
preset=TEST_WEBHOOK_PRESET_ID,
)

client = APIClient()
url = reverse("api-internal:webhooks-detail", kwargs={"pk": webhook.public_primary_key})

data = {
"preset": "some_other_preset",
}
response = client.put(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["preset"][0] == "This field once set cannot be modified."

data = {
"data": "some_other_data",
}
response = client.put(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
Loading