From 5b3dd027475d00efe421b65d57ea8fff85b12ddf Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Mon, 22 Nov 2021 12:37:07 +0100 Subject: [PATCH] Webhook tweaks + Support added for "Custom payload templates" / x-www-form-urlencoded / json (#1193) * Changed styling of webhook test & updated
component * Implements custom webhook formats Co-authored-by: Bailey Pumfleet --- lib/webhooks/sendPayload.tsx | 81 ++++++++++------ .../{subscriberUrls.tsx => subscriptions.tsx} | 8 +- pages/api/book/event.ts | 14 +-- pages/api/cancel.ts | 14 +-- pages/integrations/index.tsx | 97 +++++++++++-------- .../migration.sql | 2 + prisma/schema.prisma | 17 ++-- public/static/locales/en/common.json | 3 +- server/routers/viewer/webhook.tsx | 57 +++++------ 9 files changed, 169 insertions(+), 124 deletions(-) rename lib/webhooks/{subscriberUrls.tsx => subscriptions.tsx} (60%) create mode 100644 prisma/migrations/20211120211639_add_payload_template/migration.sql diff --git a/lib/webhooks/sendPayload.tsx b/lib/webhooks/sendPayload.tsx index fc95dffdce..6d0809a3db 100644 --- a/lib/webhooks/sendPayload.tsx +++ b/lib/webhooks/sendPayload.tsx @@ -1,38 +1,63 @@ +import { compile } from "handlebars"; + import { CalendarEvent } from "@lib/calendarClient"; -const sendPayload = ( +type ContentType = "application/json" | "application/x-www-form-urlencoded"; + +function applyTemplate(template: string, data: Omit, contentType: ContentType) { + const compiled = compile(template)(data); + if (contentType === "application/json") { + return jsonParse(compiled); + } + return compiled; +} + +function jsonParse(jsonString: string) { + try { + return JSON.parse(jsonString); + } catch (e) { + // don't do anything. + } + return false; +} + +const sendPayload = async ( triggerEvent: string, createdAt: string, subscriberUrl: string, - payload: CalendarEvent -): Promise => - new Promise((resolve, reject) => { - if (!subscriberUrl || !payload) { - return reject(new Error("Missing required elements to send webhook payload.")); - } - const body = { - triggerEvent: triggerEvent, - createdAt: createdAt, - payload: payload, - }; + data: Omit, + template?: string | null +) => { + if (!subscriberUrl || !data) { + throw new Error("Missing required elements to send webhook payload."); + } - fetch(subscriberUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }) - .then((response) => { - if (!response.ok) { - reject(new Error(`Response code ${response.status}`)); - return; - } - resolve(response); - }) - .catch((err) => { - reject(err); + const contentType = + !template || jsonParse(template) ? "application/json" : "application/x-www-form-urlencoded"; + + const body = template + ? applyTemplate(template, data, contentType) + : JSON.stringify({ + triggerEvent: triggerEvent, + createdAt: createdAt, + payload: data, }); + + const response = await fetch(subscriberUrl, { + method: "POST", + headers: { + "Content-Type": contentType, + }, + body, }); + const text = await response.text(); + + return { + ok: response.ok, + status: response.status, + message: text, + }; +}; + export default sendPayload; diff --git a/lib/webhooks/subscriberUrls.tsx b/lib/webhooks/subscriptions.tsx similarity index 60% rename from lib/webhooks/subscriberUrls.tsx rename to lib/webhooks/subscriptions.tsx index aeab311ca5..7336fb3c68 100644 --- a/lib/webhooks/subscriberUrls.tsx +++ b/lib/webhooks/subscriptions.tsx @@ -2,7 +2,7 @@ import { WebhookTriggerEvents } from "@prisma/client"; import prisma from "@lib/prisma"; -const getSubscriberUrls = async (userId: number, triggerEvent: WebhookTriggerEvents): Promise => { +const getSubscribers = async (userId: number, triggerEvent: WebhookTriggerEvents) => { const allWebhooks = await prisma.webhook.findMany({ where: { userId: userId, @@ -17,11 +17,11 @@ const getSubscriberUrls = async (userId: number, triggerEvent: WebhookTriggerEve }, select: { subscriberUrl: true, + payloadTemplate: true, }, }); - const subscriberUrls = allWebhooks.map(({ subscriberUrl }) => subscriberUrl); - return subscriberUrls; + return allWebhooks; }; -export default getSubscriberUrls; +export default getSubscribers; diff --git a/pages/api/book/event.ts b/pages/api/book/event.ts index f498a8a461..8510ceae1e 100644 --- a/pages/api/book/event.ts +++ b/pages/api/book/event.ts @@ -21,7 +21,7 @@ import prisma from "@lib/prisma"; import { BookingCreateBody } from "@lib/types/booking"; import { getBusyVideoTimes } from "@lib/videoClient"; import sendPayload from "@lib/webhooks/sendPayload"; -import getSubscriberUrls from "@lib/webhooks/subscriberUrls"; +import getSubscribers from "@lib/webhooks/subscriptions"; import { getTranslation } from "@server/lib/i18n"; @@ -494,12 +494,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const eventTrigger = rescheduleUid ? "BOOKING_RESCHEDULED" : "BOOKING_CREATED"; // Send Webhook call if hooked to BOOKING_CREATED & BOOKING_RESCHEDULED - const subscriberUrls = await getSubscriberUrls(user.id, eventTrigger); + const subscribers = await getSubscribers(user.id, eventTrigger); console.log("evt:", evt); - const promises = subscriberUrls.map((url) => - sendPayload(eventTrigger, new Date().toISOString(), url, evt).catch((e) => { - console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${url}`, e); - }) + const promises = subscribers.map((sub) => + sendPayload(eventTrigger, new Date().toISOString(), sub.subscriberUrl, evt, sub.payloadTemplate).catch( + (e) => { + console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${sub.subscriberUrl}`, e); + } + ) ); await Promise.all(promises); diff --git a/pages/api/cancel.ts b/pages/api/cancel.ts index 7c889d3f97..7baef85af5 100644 --- a/pages/api/cancel.ts +++ b/pages/api/cancel.ts @@ -11,7 +11,7 @@ import { FAKE_DAILY_CREDENTIAL } from "@lib/integrations/Daily/DailyVideoApiAdap import prisma from "@lib/prisma"; import { deleteMeeting } from "@lib/videoClient"; import sendPayload from "@lib/webhooks/sendPayload"; -import getSubscriberUrls from "@lib/webhooks/subscriberUrls"; +import getSubscribers from "@lib/webhooks/subscriptions"; import { getTranslation } from "@server/lib/i18n"; @@ -107,11 +107,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // Hook up the webhook logic here const eventTrigger = "BOOKING_CANCELLED"; // Send Webhook call if hooked to BOOKING.CANCELLED - const subscriberUrls = await getSubscriberUrls(bookingToDelete.userId, eventTrigger); - const promises = subscriberUrls.map((url) => - sendPayload(eventTrigger, new Date().toISOString(), url, evt).catch((e) => { - console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${url}`, e); - }) + const subscribers = await getSubscribers(bookingToDelete.userId, eventTrigger); + const promises = subscribers.map((sub) => + sendPayload(eventTrigger, new Date().toISOString(), sub.subscriberUrl, evt, sub.payloadTemplate).catch( + (e) => { + console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${sub.subscriberUrl}`, e); + } + ) ); await Promise.all(promises); diff --git a/pages/integrations/index.tsx b/pages/integrations/index.tsx index b08bd93fe3..69176fc5af 100644 --- a/pages/integrations/index.tsx +++ b/pages/integrations/index.tsx @@ -1,10 +1,4 @@ -import { - ChevronDownIcon, - ChevronUpIcon, - PencilAltIcon, - SwitchHorizontalIcon, - TrashIcon, -} from "@heroicons/react/outline"; +import { ChevronRightIcon, PencilAltIcon, SwitchHorizontalIcon, TrashIcon } from "@heroicons/react/outline"; import { ClipboardIcon } from "@heroicons/react/solid"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible"; import Image from "next/image"; @@ -13,7 +7,6 @@ import { Controller, useForm, useWatch } from "react-hook-form"; import { QueryCell } from "@lib/QueryCell"; import classNames from "@lib/classNames"; -import { getErrorFromUnknown } from "@lib/errors"; import { useLocale } from "@lib/hooks/useLocale"; import showToast from "@lib/notification"; import { inferQueryOutput, trpc } from "@lib/trpc"; @@ -61,7 +54,7 @@ function WebhookListItem(props: { webhook: TWebhook; onEditWebhook: () => void }
- + {props.webhook.eventTriggers.map((eventTrigger, ind) => ( void } function WebhookTestDisclosure() { const subscriberUrl: string = useWatch({ name: "subscriberUrl" }); + const payloadTemplate = useWatch({ name: "payloadTemplate" }) || null; const { t } = useLocale(); const [open, setOpen] = useState(false); const mutation = trpc.useMutation("viewer.webhook.testTrigger", { @@ -124,13 +118,9 @@ function WebhookTestDisclosure() { return ( setOpen(!open)}> - - {t("webhook_test")}{" "} - {open ? ( - - ) : ( - - )} + + + {t("webhook_test")} @@ -141,7 +131,7 @@ function WebhookTestDisclosure() { type="button" color="minimal" disabled={mutation.isLoading} - onClick={() => mutation.mutate({ url: subscriberUrl, type: "PING" })}> + onClick={() => mutation.mutate({ url: subscriberUrl, type: "PING", payloadTemplate })}> {t("ping_test")}
@@ -152,9 +142,9 @@ function WebhookTestDisclosure() {
- {mutation.data.status === 200 ? t("success") : t("failed")} + {mutation.data.ok ? t("success") : t("failed")}
{JSON.stringify(mutation.data, null, 4)}
@@ -180,9 +170,12 @@ function WebhookDialogForm(props: { eventTriggers: WEBHOOK_TRIGGER_EVENTS, subscriberUrl: "", active: true, - }, + payloadTemplate: null, + } as Omit, } = props; + const [useCustomPayloadTemplate, setUseCustomPayloadTemplate] = useState(!!defaultValues.payloadTemplate); + const form = useForm({ defaultValues, }); @@ -190,24 +183,20 @@ function WebhookDialogForm(props: { { - form - .handleSubmit(async (values) => { - if (values.id) { - await utils.client.mutation("viewer.webhook.edit", values); - await utils.invalidateQueries(["viewer.webhook.list"]); - showToast(t("webhook_updated_successfully"), "success"); - } else { - await utils.client.mutation("viewer.webhook.create", values); - await utils.invalidateQueries(["viewer.webhook.list"]); - showToast(t("webhook_created_successfully"), "success"); - } - - props.handleClose(); - })(event) - .catch((err) => { - showToast(`${getErrorFromUnknown(err).message}`, "error"); - }); + handleSubmit={async (event) => { + if (!useCustomPayloadTemplate && event.payloadTemplate) { + event.payloadTemplate = null; + } + if (event.id) { + await utils.client.mutation("viewer.webhook.edit", event); + await utils.invalidateQueries(["viewer.webhook.list"]); + showToast(t("webhook_updated_successfully"), "success"); + } else { + await utils.client.mutation("viewer.webhook.create", event); + await utils.invalidateQueries(["viewer.webhook.list"]); + showToast(t("webhook_created_successfully"), "success"); + } + props.handleClose(); }} className="space-y-4"> @@ -256,6 +245,38 @@ function WebhookDialogForm(props: { ))} +
+ {t("payload_template")} +
+ + +
+ {useCustomPayloadTemplate && ( + + )} +