diff --git a/ee/api/test/test_hooks.py b/ee/api/test/test_hooks.py index 3cfa9595ce1c8..14673f01a3b9d 100644 --- a/ee/api/test/test_hooks.py +++ b/ee/api/test/test_hooks.py @@ -1,6 +1,8 @@ from typing import cast from unittest.mock import ANY +from inline_snapshot import snapshot + from ee.api.hooks import valid_domain from ee.api.test.base import APILicensedTest from ee.models.hook import Hook @@ -117,6 +119,19 @@ def test_create_hog_function_via_hook(self): "bytecode": ["_H", HOGQL_BYTECODE_VERSION, 32, "$pageview", 32, "event", 1, 1, 11, 3, 1, 4, 1], } + assert hog_function.hog == snapshot( + """\ +let res := fetch(f'https://hooks.zapier.com/{inputs.hook}', { + 'method': 'POST', + 'body': inputs.body +}); + +if (inputs.debug) { + print('Response', res.status, res.body); +}\ +""" + ) + assert hog_function.inputs == { "body": { "bytecode": ANY, diff --git a/posthog/api/hog_function.py b/posthog/api/hog_function.py index b112919b5870c..c59a70c5542e3 100644 --- a/posthog/api/hog_function.py +++ b/posthog/api/hog_function.py @@ -152,11 +152,12 @@ def validate(self, attrs): instance = cast(Optional[HogFunction], self.context.get("instance", self.instance)) hog_type = attrs.get("type", instance.type if instance else "destination") + is_create = self.context.get("view") and self.context["view"].action == "create" - if not has_addon: - template_id = attrs.get("template_id", instance.template_id if instance else None) - template = HOG_FUNCTION_TEMPLATES_BY_ID.get(template_id, None) + template_id = attrs.get("template_id", instance.template_id if instance else None) + template = HOG_FUNCTION_TEMPLATES_BY_ID.get(template_id, None) + if not has_addon: # In this case they are only allowed to create or update the function with free templates if not template: raise serializers.ValidationError( @@ -168,18 +169,23 @@ def validate(self, attrs): {"template_id": "The Data Pipelines addon is required for this template."} ) - # Without the addon, they cannot deviate from the template - attrs["inputs_schema"] = template.inputs_schema - attrs["mappings"] = template.mappings + # Without the addon you can't deviate from the template attrs["hog"] = template.hog + attrs["inputs_schema"] = template.inputs_schema - if self.context.get("view") and self.context["view"].action == "create": + if is_create: # Ensure we have sensible defaults when created attrs["filters"] = attrs.get("filters") or {} attrs["inputs_schema"] = attrs.get("inputs_schema") or [] attrs["inputs"] = attrs.get("inputs") or {} attrs["mappings"] = attrs.get("mappings") or None + # And if there is a template, use the template values if not overridden + if template: + attrs["hog"] = attrs.get("hog") or template.hog + attrs["inputs_schema"] = attrs.get("inputs_schema") or template.inputs_schema + attrs["inputs"] = attrs.get("inputs") or {} + # Used for both top level input validation, and mappings input validation def validate_input_and_filters(attrs: dict): if "inputs_schema" in attrs: @@ -235,6 +241,10 @@ def validate_input_and_filters(attrs: dict): attrs["bytecode"] = None attrs["transpiled"] = None + if is_create: + if not attrs.get("hog"): + raise serializers.ValidationError({"hog": "Required."}) + return super().validate(attrs) def to_representation(self, data): diff --git a/posthog/api/test/test_hog_function.py b/posthog/api/test/test_hog_function.py index bd68c2ce66506..97420068cb5c6 100644 --- a/posthog/api/test/test_hog_function.py +++ b/posthog/api/test/test_hog_function.py @@ -264,11 +264,14 @@ def test_creates_with_template_id(self, *args): "name": "Fetch URL", "description": "Test description", "hog": "fetch(inputs.url);", + "inputs": {"url": {"value": "https://example.com"}}, "template_id": template_webhook.id, "type": "destination", }, ) assert response.status_code == status.HTTP_201_CREATED, response.json() + + assert response.json()["hog"] == "fetch(inputs.url);" assert response.json()["template"] == { "type": "destination", "name": template_webhook.name, @@ -286,14 +289,35 @@ def test_creates_with_template_id(self, *args): "sub_templates": response.json()["template"]["sub_templates"], } + def test_creates_with_template_values_if_not_provided(self, *args): + payload: dict = { + "name": "Fetch URL", + "description": "Test description", + "template_id": template_webhook.id, + "type": "destination", + } + response = self.client.post(f"/api/projects/{self.team.id}/hog_functions/", data=payload) + assert response.status_code == status.HTTP_400_BAD_REQUEST, response.json() + assert response.json() == { + "attr": "inputs__url", + "code": "invalid_input", + "detail": "This field is required.", + "type": "validation_error", + } + + payload["inputs"] = {"url": {"value": "https://example.com"}} + + response = self.client.post(f"/api/projects/{self.team.id}/hog_functions/", data=payload) + assert response.status_code == status.HTTP_201_CREATED, response.json() + assert response.json()["hog"] == template_webhook.hog + assert response.json()["inputs_schema"] == template_webhook.inputs_schema + def test_deletes_via_update(self, *args): response = self.client.post( f"/api/projects/{self.team.id}/hog_functions/", data={ - "type": "destination", + **EXAMPLE_FULL, "name": "Fetch URL", - "description": "Test description", - "hog": "fetch(inputs.url);", }, ) assert response.status_code == status.HTTP_201_CREATED, response.json() @@ -640,8 +664,7 @@ def test_generates_hog_bytecode(self, *args): response = self.client.post( f"/api/projects/{self.team.id}/hog_functions/", data={ - "type": "destination", - "name": "Fetch URL", + **EXAMPLE_FULL, "hog": "let i := 0;\nwhile(i < 3) {\n i := i + 1;\n fetch(inputs.url, {\n 'headers': {\n 'x-count': f'{i}'\n },\n 'body': inputs.payload,\n 'method': inputs.method\n });\n}", }, ) @@ -882,7 +905,10 @@ def test_patches_status_on_enabled_update(self, *args): response = self.client.post( f"/api/projects/{self.team.id}/hog_functions/", - data={"type": "destination", "name": "Fetch URL", "hog": "fetch(inputs.url);", "enabled": True}, + data={ + **EXAMPLE_FULL, + "name": "Fetch URL", + }, ) id = response.json()["id"] @@ -1110,11 +1136,7 @@ def test_create_hog_function_with_site_destination_type(self): def test_cannot_modify_type_of_existing_hog_function(self): response = self.client.post( f"/api/projects/{self.team.id}/hog_functions/", - data={ - "name": "Site Destination Function", - "hog": "export function onLoad() { console.log('Hello, site_destination'); }", - "type": "site_destination", - }, + data=EXAMPLE_FULL, ) assert response.status_code == status.HTTP_201_CREATED, response.json() @@ -1134,11 +1156,7 @@ def test_cannot_modify_type_of_existing_hog_function(self): def test_transpiled_field_not_populated_for_other_types(self): response = self.client.post( f"/api/projects/{self.team.id}/hog_functions/", - data={ - "name": "Regular Function", - "hog": "fetch(inputs.url);", - "type": "destination", - }, + data=EXAMPLE_FULL, ) assert response.status_code == status.HTTP_201_CREATED, response.json()