Skip to content

Commit b0ef912

Browse files
authored
chore: webhook notifications add headers (openstatusHQ#2025)
* chore: webhook otifications add headers * fix: review * fix: test * fix: final stuff
1 parent f517071 commit b0ef912

File tree

5 files changed

+120
-31
lines changed

5 files changed

+120
-31
lines changed

apps/dashboard/src/components/forms/notifications/form-webhook.tsx

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,28 @@ import { Label } from "@openstatus/ui/components/ui/label";
2525
import { cn } from "@openstatus/ui/lib/utils";
2626
import { useMutation } from "@tanstack/react-query";
2727
import { isTRPCClientError } from "@trpc/client";
28+
import { Plus, X } from "lucide-react";
2829
import React, { useTransition } from "react";
29-
import { useForm } from "react-hook-form";
30+
import { useFieldArray, useForm } from "react-hook-form";
3031
import { toast } from "sonner";
3132
import { z } from "zod";
3233

3334
const schema = z.object({
3435
name: z.string(),
3536
provider: z.literal("webhook"),
36-
data: z.record(z.string(), z.string()),
37+
data: z.object({
38+
endpoint: z.string().url(),
39+
headers: z.array(
40+
z.object({
41+
key: z.string().min(1, "Key is required"),
42+
value: z.string(),
43+
}),
44+
),
45+
}),
3746
monitors: z.array(z.number()),
3847
});
3948

40-
type FormValues = z.infer<typeof schema>;
49+
type FormValues = z.input<typeof schema>;
4150

4251
export function FormWebhook({
4352
defaultValues,
@@ -57,11 +66,15 @@ export function FormWebhook({
5766
provider: "webhook",
5867
data: {
5968
endpoint: "",
60-
// headers: []
69+
headers: [],
6170
},
6271
monitors: [],
6372
},
6473
});
74+
const { fields, append, remove } = useFieldArray({
75+
control: form.control,
76+
name: "data.headers",
77+
});
6578
const [isPending, startTransition] = useTransition();
6679
const { setIsDirty } = useFormSheetDirty();
6780
const trpc = useTRPC();
@@ -105,10 +118,11 @@ export function FormWebhook({
105118
try {
106119
const provider = form.getValues("provider");
107120
const endpoint = form.getValues("data.endpoint");
121+
const headers = form.getValues("data.headers");
108122
const promise = sendTestMutation.mutateAsync({
109123
provider,
110124
data: {
111-
webhook: { endpoint },
125+
webhook: { endpoint, headers },
112126
},
113127
});
114128
toast.promise(promise, {
@@ -176,6 +190,60 @@ export function FormWebhook({
176190
</FormItem>
177191
)}
178192
/>
193+
<FormItem>
194+
<FormLabel>Request Headers</FormLabel>
195+
<FormDescription>
196+
Custom headers to include in every webhook request.
197+
</FormDescription>
198+
{fields.map((field, index) => (
199+
<div key={field.id} className="grid gap-2 sm:grid-cols-5">
200+
<FormField
201+
control={form.control}
202+
name={`data.headers.${index}.key`}
203+
render={({ field }) => (
204+
<FormItem className="col-span-2">
205+
<FormControl>
206+
<Input placeholder="Key" {...field} />
207+
</FormControl>
208+
<FormMessage />
209+
</FormItem>
210+
)}
211+
/>
212+
<FormField
213+
control={form.control}
214+
name={`data.headers.${index}.value`}
215+
render={({ field }) => (
216+
<FormItem className="col-span-2">
217+
<FormControl>
218+
<Input placeholder="Value" {...field} />
219+
</FormControl>
220+
</FormItem>
221+
)}
222+
/>
223+
<Button
224+
size="icon"
225+
variant="ghost"
226+
type="button"
227+
aria-label="Remove header"
228+
onClick={() => remove(index)}
229+
>
230+
<X />
231+
</Button>
232+
</div>
233+
))}
234+
<div>
235+
<Button
236+
size="sm"
237+
variant="outline"
238+
type="button"
239+
onClick={() => append({ key: "", value: "" })}
240+
>
241+
<Plus />
242+
Add Header
243+
</Button>
244+
</div>
245+
<FormMessage />
246+
</FormItem>
179247
<div>
180248
<Button
181249
variant="outline"

apps/dashboard/src/components/forms/notifications/form.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ const schema = z.object({
3636
"google-chat",
3737
"grafana-oncall",
3838
]),
39-
data: z.record(z.string(), z.string()).or(z.string()),
39+
data: z.record(z.string(), z.any()).or(z.string()),
4040
monitors: z.array(z.number()),
4141
});
4242

packages/api/src/router/notification.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,18 @@ export const notificationRouter = createTRPCRouter({
8181
name: z.string(),
8282
data: z.partialRecord(
8383
z.enum(notificationProvider),
84-
z.string().or(z.record(z.string(), z.string())),
84+
z
85+
.string()
86+
.or(
87+
z.record(
88+
z.string(),
89+
z
90+
.string()
91+
.or(
92+
z.array(z.object({ key: z.string(), value: z.string() })),
93+
),
94+
),
95+
),
8596
),
8697
monitors: z.array(z.number()),
8798
}),
@@ -163,7 +174,14 @@ export const notificationRouter = createTRPCRouter({
163174
provider: z.enum(notificationProvider),
164175
data: z.partialRecord(
165176
z.enum(notificationProvider),
166-
z.record(z.string(), z.string()).or(z.string()),
177+
z
178+
.record(
179+
z.string(),
180+
z
181+
.string()
182+
.or(z.array(z.object({ key: z.string(), value: z.string() }))),
183+
)
184+
.or(z.string()),
167185
),
168186
name: z.string(),
169187
monitors: z.array(z.number()).prefault([]),
@@ -284,7 +302,14 @@ export const notificationRouter = createTRPCRouter({
284302
provider: z.enum(notificationProvider),
285303
data: z.partialRecord(
286304
z.enum(notificationProvider),
287-
z.record(z.string(), z.string()).or(z.string()),
305+
z
306+
.record(
307+
z.string(),
308+
z
309+
.string()
310+
.or(z.array(z.object({ key: z.string(), value: z.string() }))),
311+
)
312+
.or(z.string()),
288313
),
289314
}),
290315
)

packages/notifications/webhook/src/index.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,8 @@ describe("Webhook sendTest", () => {
100100
expect(result).toBe(true);
101101
expect(fetchMock).toHaveBeenCalledTimes(1);
102102
const callArgs = fetchMock.mock.calls[0];
103-
// Empty headers array should result in empty object after transformHeaders
104-
expect(callArgs[1].headers).toEqual({});
103+
// Empty headers array should still include Content-Type
104+
expect(callArgs[1].headers).toEqual({ "Content-Type": "application/json" });
105105
});
106106

107107
test("should send test webhook with 500 status code", async () => {

packages/notifications/webhook/src/index.ts

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,10 @@ export const sendAlert = async ({
2424
const res = await fetch(notificationData.webhook.endpoint, {
2525
method: "post",
2626
body: JSON.stringify(body),
27-
headers: notificationData.webhook.headers
28-
? transformHeaders(notificationData.webhook.headers)
29-
: {
30-
"Content-Type": "application/json",
31-
},
27+
headers: {
28+
"Content-Type": "application/json",
29+
...transformHeaders(notificationData.webhook.headers ?? []),
30+
},
3231
});
3332
if (!res.ok) {
3433
throw new Error(`Failed to send webhook notification: ${res.statusText}`);
@@ -57,11 +56,10 @@ export const sendRecovery = async ({
5756
const res = await fetch(url, {
5857
method: "post",
5958
body: JSON.stringify(body),
60-
headers: notificationData.webhook.headers
61-
? transformHeaders(notificationData.webhook.headers)
62-
: {
63-
"Content-Type": "application/json",
64-
},
59+
headers: {
60+
"Content-Type": "application/json",
61+
...transformHeaders(notificationData.webhook.headers ?? []),
62+
},
6563
});
6664
if (!res.ok) {
6765
throw new Error(`Failed to send webhook notification: ${res.statusText}`);
@@ -90,11 +88,10 @@ export const sendDegraded = async ({
9088
const res = await fetch(notificationData.webhook.endpoint, {
9189
method: "post",
9290
body: JSON.stringify(body),
93-
headers: notificationData.webhook.headers
94-
? transformHeaders(notificationData.webhook.headers)
95-
: {
96-
"Content-Type": "application/json",
97-
},
91+
headers: {
92+
"Content-Type": "application/json",
93+
...transformHeaders(notificationData.webhook.headers ?? []),
94+
},
9895
});
9996
if (!res.ok) {
10097
throw new Error(`Failed to send webhook notification: ${res.statusText}`);
@@ -123,11 +120,10 @@ export const sendTest = async ({
123120
const response = await fetch(url, {
124121
method: "post",
125122
body: JSON.stringify(body),
126-
headers: headers
127-
? transformHeaders(headers)
128-
: {
129-
"Content-Type": "application/json",
130-
},
123+
headers: {
124+
"Content-Type": "application/json",
125+
...transformHeaders(headers ?? []),
126+
},
131127
});
132128
if (!response.ok) {
133129
throw new Error("Failed to send test");

0 commit comments

Comments
 (0)