enhanced webhooks to support custom headers and payload templates

This commit is contained in:
dekzter 2026-02-25 10:59:45 -05:00
parent 03c8d55ab9
commit 4d6351507f
4 changed files with 239 additions and 73 deletions

View file

@ -74,11 +74,13 @@ class IntegrationViewSet(viewsets.ModelViewSet):
{"detail": f"Invalid event: {event}"},
status=status.HTTP_400_BAD_REQUEST,
)
# Only accept payload_template when the integration is a webhook
payload_template = item.get("payload_template") if integration.type == "webhook" else None
incoming.append(
{
"event": event,
"enabled": bool(item.get("enabled", True)),
"payload_template": item.get("payload_template"),
"payload_template": payload_template,
}
)

View file

@ -1,10 +1,21 @@
# connect/handlers/webhook.py
import requests
import requests, json, logging
from .base import IntegrationHandler
logger = logging.getLogger(__name__)
class WebhookHandler(IntegrationHandler):
def execute(self):
url = self.integration.config.get("url")
headers = self.integration.config.get("headers", {})
response = requests.post(url, json=self.payload, headers=headers, timeout=10)
logger.info(self.payload)
try:
parsed = json.loads(self.payload)
headers["Content-Type"] = "application/json"
except Exception:
pass
response = requests.post(url, data=self.payload, headers=headers, timeout=10)
return {"status_code": response.status_code, "body": response.text, "success": response.ok}

View file

@ -38,13 +38,14 @@ def trigger_event(event_name, payload):
)
continue
# apply optional payload template
# apply optional payload template (only for webhook integrations)
# If the rendered template is valid JSON, use that object as the payload.
# Otherwise, pass the rendered string as-is.
final_payload = payload
if sub.payload_template:
if integration.type == 'webhook' and sub.payload_template:
try:
template = Template(sub.payload_template)
rendered = template.render(Context(payload))
final_payload = {"message": rendered}
final_payload = template.render(Context(payload)).strip()
except Exception as e:
logger.error(
f"Payload template render failed for subscription id={sub.id}: {e}"

View file

@ -11,6 +11,11 @@ import {
Checkbox,
Text,
SimpleGrid,
Textarea,
Group,
Tabs,
Accordion,
Alert,
} from '@mantine/core';
import { isNotEmpty, useForm } from '@mantine/form';
import { SUBSCRIPTION_EVENTS } from '../../constants';
@ -25,6 +30,8 @@ const EVENT_OPTIONS = Object.entries(SUBSCRIPTION_EVENTS).map(
const ConnectionForm = ({ connection = null, isOpen, onClose }) => {
const [submitting, setSubmitting] = useState(false);
const [selectedEvents, setSelectedEvents] = useState([]);
const [headers, setHeaders] = useState([]);
const [payloadTemplates, setPayloadTemplates] = useState({});
const [apiError, setApiError] = useState('');
// One-time form
@ -71,9 +78,24 @@ const ConnectionForm = ({ connection = null, isOpen, onClose }) => {
return acc;
}, [])
);
// Initialize headers array from config.headers object
const cfgHeaders = connection.config?.headers || {};
const hdrs = Object.keys(cfgHeaders).length
? Object.entries(cfgHeaders).map(([k, v]) => ({ key: k, value: v }))
: [{ key: '', value: '' }];
setHeaders(hdrs);
// Initialize payload templates per subscription
const templates = {};
connection.subscriptions.forEach((sub) => {
if (sub.payload_template) templates[sub.event] = sub.payload_template;
});
setPayloadTemplates(templates);
} else {
form.reset();
setSelectedEvents([]);
setHeaders([{ key: '', value: '' }]);
setPayloadTemplates({});
}
}, [connection]);
@ -87,10 +109,18 @@ const ConnectionForm = ({ connection = null, isOpen, onClose }) => {
try {
setSubmitting(true);
setApiError('');
const config =
values.type === 'webhook'
? { url: values.url }
: { path: values.script_path };
// Build config including optional headers
let config;
if (values.type === 'webhook') {
const hdrs = {};
headers.forEach((h) => {
if (h.key && h.key.trim()) hdrs[h.key] = h.value;
});
config = { url: values.url };
if (Object.keys(hdrs).length) config.headers = hdrs;
} else {
config = { path: values.script_path };
}
if (connection) {
await API.updateConnectIntegration(connection.id, {
@ -108,13 +138,14 @@ const ConnectionForm = ({ connection = null, isOpen, onClose }) => {
});
}
await API.setConnectSubscriptions(
connection.id,
Object.keys(SUBSCRIPTION_EVENTS).map((event) => ({
event,
enabled: selectedEvents.includes(event),
}))
);
// Build subscription list including optional payload templates
const subs = Object.keys(SUBSCRIPTION_EVENTS).map((event) => ({
event,
enabled: selectedEvents.includes(event),
payload_template: payloadTemplates[event] || null,
}));
await API.setConnectSubscriptions(connection.id, subs);
handleClose();
} catch (error) {
console.error('Failed to create/update connection', error);
@ -164,64 +195,185 @@ const ConnectionForm = ({ connection = null, isOpen, onClose }) => {
return (
<Modal opened={isOpen} size="lg" onClose={handleClose} title="Connection">
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap="md">
{apiError ? (
<Text c="red" size="sm">
{apiError}
</Text>
) : null}
<TextInput
label="Name"
{...form.getInputProps('name')}
key={form.key('name')}
/>
<Select
{...form.getInputProps('type')}
key={form.key('type')}
label="Connection Type"
data={[
{ value: 'webhook', label: 'Webhook' },
{ value: 'script', label: 'Custom Script' },
]}
/>
{form.getValues().type === 'webhook' ? (
<TextInput
label="Webhook URL"
{...form.getInputProps('url')}
key={form.key('url')}
/>
) : (
<TextInput
label="Script Path"
{...form.getInputProps('script_path')}
key={form.key('script_path')}
/>
)}
<Tabs defaultValue="settings">
<Tabs.List>
<Tabs.Tab value="settings">Settings</Tabs.Tab>
<Tabs.Tab value="triggers">Event Triggers</Tabs.Tab>
{form.getValues().type === 'webhook' && (
<Tabs.Tab value="templates">Payload Templates</Tabs.Tab>
)}
</Tabs.List>
<Box>
<Text size="sm" weight={500} mb={5}>
Event Triggers
</Text>
<Stack gap="xs">
<SimpleGrid cols={3}>
{EVENT_OPTIONS.map((opt) => (
<Checkbox
key={opt.value}
label={opt.label}
checked={selectedEvents.includes(opt.value)}
onChange={() => toggleEvent(opt.value)}
/>
))}
</SimpleGrid>
<Tabs.Panel value="settings" style={{ paddingTop: 10 }}>
<Stack gap="md">
{apiError ? (
<Text c="red" size="sm">
{apiError}
</Text>
) : null}
<TextInput
label="Name"
{...form.getInputProps('name')}
key={form.key('name')}
/>
<Select
{...form.getInputProps('type')}
key={form.key('type')}
label="Connection Type"
data={[
{ value: 'webhook', label: 'Webhook' },
{ value: 'script', label: 'Custom Script' },
]}
/>
{form.getValues().type === 'webhook' ? (
<TextInput
label="Webhook URL"
{...form.getInputProps('url')}
key={form.key('url')}
/>
) : (
<TextInput
label="Script Path"
{...form.getInputProps('script_path')}
key={form.key('script_path')}
/>
)}
{form.getValues().type === 'webhook' ? (
<Box>
<Text size="sm" weight={500} mb={5}>
Custom Headers (optional)
</Text>
<Stack spacing="xs">
{headers.map((h, idx) => (
<Group key={idx} align="flex-start">
<TextInput
placeholder="Header name"
value={h.key}
onChange={(e) => {
const next = [...headers];
next[idx] = { ...next[idx], key: e.target.value };
setHeaders(next);
}}
style={{ flex: 1 }}
/>
<TextInput
placeholder="Header value"
value={h.value}
onChange={(e) => {
const next = [...headers];
next[idx] = {
...next[idx],
value: e.target.value,
};
setHeaders(next);
}}
style={{ flex: 1 }}
/>
<Button
size="xs"
color="red"
onClick={() => {
const next = headers.filter((_, i) => i !== idx);
setHeaders(
next.length ? next : [{ key: '', value: '' }]
);
}}
>
Remove
</Button>
</Group>
))}
<Button
size="xs"
onClick={() =>
setHeaders([...headers, { key: '', value: '' }])
}
>
Add Header
</Button>
</Stack>
</Box>
) : null}
</Stack>
</Box>
</Tabs.Panel>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button type="submit" loading={submitting}>
Save
</Button>
</Flex>
</Stack>
<Tabs.Panel value="triggers" style={{ paddingTop: 10 }}>
<SimpleGrid cols={3}>
{EVENT_OPTIONS.map((opt) => (
<Checkbox
key={opt.value}
label={opt.label}
checked={selectedEvents.includes(opt.value)}
onChange={() => toggleEvent(opt.value)}
/>
))}
</SimpleGrid>
</Tabs.Panel>
{form.getValues().type === 'webhook' && (
<Tabs.Panel value="templates" style={{ paddingTop: 10 }}>
<Stack gap="xs">
<Alert variant="default">
<Text size="sm">
Enable event triggers to set individual templates.
</Text>
</Alert>
<div
style={{
maxHeight: '60vh',
display: 'flex',
flexDirection: 'column',
}}
>
<div style={{ overflow: 'auto', flex: 1, minHeight: 0 }}>
<Accordion
multiple={true}
styles={{
label: { padding: 2 },
}}
>
{EVENT_OPTIONS.map(
(opt) =>
selectedEvents.includes(opt.value) && (
<Accordion.Item key={opt.value} value={opt.value}>
<Accordion.Control
disabled={!selectedEvents.includes(opt.value)}
style={{ paddingTop: 4, paddingBottom: 4 }}
>
{opt.label}
</Accordion.Control>
<Accordion.Panel>
<Textarea
placeholder={
'Optional Jinja2 payload template (use variables from event payload)'
}
minRows={3}
value={payloadTemplates[opt.value] || ''}
autosize
onChange={(e) =>
setPayloadTemplates({
...payloadTemplates,
[opt.value]: e.target.value,
})
}
/>
</Accordion.Panel>
</Accordion.Item>
)
)}
</Accordion>
</div>
</div>
</Stack>
</Tabs.Panel>
)}
</Tabs>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button type="submit" loading={submitting}>
Save
</Button>
</Flex>
</form>
</Modal>
);