Webhook tweaks + Support added for "Custom payload templates" / x-www-form-urlencoded / json (#1193)

* Changed styling of webhook test & updated <Form> component

* Implements custom webhook formats

Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
This commit is contained in:
Alex van Andel 2021-11-22 12:37:07 +01:00 committed by GitHub
parent ecc960f0a3
commit 5b3dd02747
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 169 additions and 124 deletions

View File

@ -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<CalendarEvent, "language">, 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<string | Response> =>
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<CalendarEvent, "language">,
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;

View File

@ -2,7 +2,7 @@ import { WebhookTriggerEvents } from "@prisma/client";
import prisma from "@lib/prisma";
const getSubscriberUrls = async (userId: number, triggerEvent: WebhookTriggerEvents): Promise<string[]> => {
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;

View File

@ -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);

View File

@ -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);

View File

@ -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 }
</span>
</div>
<div className="flex mt-2">
<span className="flex flex-col space-y-1 sm:space-y-0 text-xs sm:flex-row sm:space-x-2">
<span className="flex flex-col space-y-1 text-xs sm:space-y-0 sm:flex-row sm:space-x-2">
{props.webhook.eventTriggers.map((eventTrigger, ind) => (
<span
key={ind}
@ -114,6 +107,7 @@ function WebhookListItem(props: { webhook: TWebhook; onEditWebhook: () => 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 (
<Collapsible open={open} onOpenChange={() => setOpen(!open)}>
<CollapsibleTrigger type="button" className={"cursor-pointer flex w-full text-sm"}>
{t("webhook_test")}{" "}
{open ? (
<ChevronUpIcon className="w-5 h-5 text-gray-700" />
) : (
<ChevronDownIcon className="w-5 h-5 text-gray-700" />
)}
<CollapsibleTrigger type="button" className={"cursor-pointer flex w-full"}>
<ChevronRightIcon className={`${open ? "transform rotate-90" : ""} w-5 h-5 text-neutral-500`} />
<span className="text-sm font-medium text-gray-700">{t("webhook_test")}</span>
</CollapsibleTrigger>
<CollapsibleContent>
<InputGroupBox className="px-0 space-y-0 border-0">
@ -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")}
</Button>
</div>
@ -152,9 +142,9 @@ function WebhookTestDisclosure() {
<div
className={classNames(
"px-2 py-1 w-max text-xs ml-auto",
mutation.data.status === 200 ? "text-green-500 bg-green-50" : "text-red-500 bg-red-50"
mutation.data.ok ? "text-green-500 bg-green-50" : "text-red-500 bg-red-50"
)}>
{mutation.data.status === 200 ? t("success") : t("failed")}
{mutation.data.ok ? t("success") : t("failed")}
</div>
<pre className="overflow-x-auto">{JSON.stringify(mutation.data, null, 4)}</pre>
</>
@ -180,9 +170,12 @@ function WebhookDialogForm(props: {
eventTriggers: WEBHOOK_TRIGGER_EVENTS,
subscriberUrl: "",
active: true,
},
payloadTemplate: null,
} as Omit<TWebhook, "userId" | "createdAt">,
} = props;
const [useCustomPayloadTemplate, setUseCustomPayloadTemplate] = useState(!!defaultValues.payloadTemplate);
const form = useForm({
defaultValues,
});
@ -190,24 +183,20 @@ function WebhookDialogForm(props: {
<Form
data-testid="WebhookDialogForm"
form={form}
onSubmit={(event) => {
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">
<input type="hidden" {...form.register("id")} />
@ -256,6 +245,38 @@ function WebhookDialogForm(props: {
))}
</InputGroupBox>
</fieldset>
<fieldset className="space-y-2">
<FieldsetLegend>{t("payload_template")}</FieldsetLegend>
<div className="space-x-3 text-sm">
<label>
<input
className="text-neutral-900 focus:ring-neutral-500"
type="radio"
name="useCustomPayloadTemplate"
onChange={(value) => setUseCustomPayloadTemplate(!value.target.checked)}
defaultChecked={!useCustomPayloadTemplate}
/>{" "}
Default
</label>
<label>
<input
className="text-neutral-900 focus:ring-neutral-500"
onChange={(value) => setUseCustomPayloadTemplate(value.target.checked)}
name="useCustomPayloadTemplate"
type="radio"
defaultChecked={useCustomPayloadTemplate}
/>{" "}
Custom
</label>
</div>
{useCustomPayloadTemplate && (
<textarea
{...form.register("payloadTemplate")}
className="block w-full font-mono border-gray-300 rounded-sm shadow-sm focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
rows={5}
defaultValue={useCustomPayloadTemplate && (defaultValues.payloadTemplate || "")}></textarea>
)}
</fieldset>
<WebhookTestDisclosure />
<DialogFooter>
<Button type="button" color="secondary" onClick={props.handleClose} tabIndex={-1}>

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Webhook" ADD COLUMN "payloadTemplate" TEXT;

View File

@ -100,7 +100,7 @@ model User {
plan UserPlan @default(PRO)
Schedule Schedule[]
webhooks Webhook[]
brandColor String @default("#292929")
brandColor String @default("#292929")
@@map(name: "users")
}
@ -301,11 +301,12 @@ enum WebhookTriggerEvents {
}
model Webhook {
id String @id @unique
userId Int
subscriberUrl String
createdAt DateTime @default(now())
active Boolean @default(true)
eventTriggers WebhookTriggerEvents[]
user User @relation(fields: [userId], references: [id])
id String @id @unique
userId Int
subscriberUrl String
payloadTemplate String?
createdAt DateTime @default(now())
active Boolean @default(true)
eventTriggers WebhookTriggerEvents[]
user User @relation(fields: [userId], references: [id])
}

View File

@ -79,6 +79,7 @@
"webhook_created_successfully": "Webhook created successfully!",
"webhook_updated_successfully": "Webhook updated successfully!",
"webhook_removed_successfully": "Webhook removed successfully!",
"payload_template": "Payload Template",
"dismiss": "Dismiss",
"no_data_yet": "No data yet",
"ping_test": "Ping test",
@ -533,4 +534,4 @@
"not_installed": "Not installed",
"error_password_mismatch": "Passwords don't match.",
"error_required_field": "This field is required."
}
}

View File

@ -3,6 +3,7 @@ import { z } from "zod";
import { getErrorFromUnknown } from "@lib/errors";
import { WEBHOOK_TRIGGER_EVENTS } from "@lib/webhooks/constants";
import sendPayload from "@lib/webhooks/sendPayload";
import { createProtectedRouter } from "@server/createRouter";
@ -38,6 +39,7 @@ export const webhookRouter = createProtectedRouter()
subscriberUrl: z.string().url().optional(),
eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array().optional(),
active: z.boolean().optional(),
payloadTemplate: z.string().nullable(),
}),
async resolve({ ctx, input }) {
const { id, ...data } = input;
@ -88,50 +90,39 @@ export const webhookRouter = createProtectedRouter()
input: z.object({
url: z.string().url(),
type: z.string(),
payloadTemplate: z.string().optional().nullable(),
}),
async resolve({ input }) {
const { url, type } = input;
const { url, type, payloadTemplate } = input;
const responseBodyMocks: Record<"PING", unknown> = {
PING: {
triggerEvent: "PING",
createdAt: new Date().toISOString(),
payload: {
type: "Test",
title: "Test trigger event",
description: "",
startTime: new Date().toISOString(),
endTime: new Date().toISOString(),
organizer: {
name: "Cal",
email: "",
timeZone: "Europe/London",
},
const data = {
type: "Test",
title: "Test trigger event",
description: "",
startTime: new Date().toISOString(),
endTime: new Date().toISOString(),
attendees: [
{
email: "jdoe@example.com",
name: "John Doe",
timeZone: "Europe/London",
},
],
organizer: {
name: "Cal",
email: "",
timeZone: "Europe/London",
},
};
const body = responseBodyMocks[type as "PING"];
if (!body) {
throw new Error(`Unknown type '${type}'`);
}
try {
const res = await fetch(url, {
method: "POST",
// [...]
body: JSON.stringify(body),
});
const text = await res.text();
return {
status: res.status,
message: text,
};
return await sendPayload(type, new Date().toISOString(), url, data, payloadTemplate);
} catch (_err) {
const err = getErrorFromUnknown(_err);
const error = getErrorFromUnknown(_err);
return {
ok: false,
status: 500,
message: err.message,
message: error.message,
};
}
},