refactor(webhooks): headers as array

This commit is contained in:
Nicolas Meienberger 2026-04-25 11:26:16 +02:00
parent f439fb39a1
commit be7eef7028
No known key found for this signature in database
15 changed files with 129 additions and 106 deletions

View file

@ -2436,12 +2436,12 @@ export type ListBackupSchedulesResponses = {
customResticParams: Array<string> | null;
preBackupWebhook: {
url: string;
headers?: Record<string, string>;
headers?: Array<string>;
body?: string;
} | null;
postBackupWebhook: {
url: string;
headers?: Record<string, string>;
headers?: Array<string>;
body?: string;
} | null;
maxRetries: number;
@ -2724,12 +2724,12 @@ export type CreateBackupScheduleData = {
customResticParams?: Array<string>;
preBackupWebhook?: {
url: string;
headers?: Record<string, string>;
headers?: Array<string>;
body?: string;
} | null;
postBackupWebhook?: {
url: string;
headers?: Record<string, string>;
headers?: Array<string>;
body?: string;
} | null;
maxRetries?: number;
@ -2769,12 +2769,12 @@ export type CreateBackupScheduleResponses = {
customResticParams: Array<string> | null;
preBackupWebhook: {
url: string;
headers?: Record<string, string>;
headers?: Array<string>;
body?: string;
} | null;
postBackupWebhook: {
url: string;
headers?: Record<string, string>;
headers?: Array<string>;
body?: string;
} | null;
maxRetries: number;
@ -2848,12 +2848,12 @@ export type GetBackupScheduleResponses = {
customResticParams: Array<string> | null;
preBackupWebhook: {
url: string;
headers?: Record<string, string>;
headers?: Array<string>;
body?: string;
} | null;
postBackupWebhook: {
url: string;
headers?: Record<string, string>;
headers?: Array<string>;
body?: string;
} | null;
maxRetries: number;
@ -3135,12 +3135,12 @@ export type UpdateBackupScheduleData = {
customResticParams?: Array<string>;
preBackupWebhook?: {
url: string;
headers?: Record<string, string>;
headers?: Array<string>;
body?: string;
} | null;
postBackupWebhook?: {
url: string;
headers?: Record<string, string>;
headers?: Array<string>;
body?: string;
} | null;
maxRetries?: number;
@ -3182,12 +3182,12 @@ export type UpdateBackupScheduleResponses = {
customResticParams: Array<string> | null;
preBackupWebhook: {
url: string;
headers?: Record<string, string>;
headers?: Array<string>;
body?: string;
} | null;
postBackupWebhook: {
url: string;
headers?: Record<string, string>;
headers?: Array<string>;
body?: string;
} | null;
maxRetries: number;
@ -3241,12 +3241,12 @@ export type GetBackupScheduleForVolumeResponses = {
customResticParams: Array<string> | null;
preBackupWebhook: {
url: string;
headers?: Record<string, string>;
headers?: Array<string>;
body?: string;
} | null;
postBackupWebhook: {
url: string;
headers?: Record<string, string>;
headers?: Array<string>;
body?: string;
} | null;
maxRetries: number;

View file

@ -0,0 +1,25 @@
import { CodeBlock } from "~/client/components/ui/code-block";
import { Label } from "~/client/components/ui/label";
type WebhookRequestPreviewProps = {
method: string;
url?: string;
contentType?: string;
headers?: string[];
body: string;
};
export const WebhookRequestPreview = ({ method, url, contentType, headers, body }: WebhookRequestPreviewProps) => {
const headerLines = [contentType ? `Content-Type: ${contentType}` : undefined, ...(headers ?? [])].filter(Boolean);
const previewCode = `${method} ${url || "https://api.example.com/webhook"}${headerLines.length > 0 ? `\n${headerLines.join("\n")}` : ""}
${body}`;
return (
<div className="space-y-2 pt-4 border-t">
<Label>Request Preview</Label>
<CodeBlock code={previewCode} filename="HTTP Request" />
<p className="text-[0.8rem] text-muted-foreground">This is a preview of the HTTP request that will be sent.</p>
</div>
);
};

View file

@ -1,6 +1,6 @@
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
import { Textarea } from "~/client/components/ui/textarea";
import type { UseFormReturn } from "react-hook-form";
import { useWatch, type UseFormReturn } from "react-hook-form";
import type { InternalFormValues } from "./types";
import { Input } from "~/client/components/ui/input";
@ -9,6 +9,10 @@ type AdvancedSectionProps = {
};
export const AdvancedSection = ({ form }: AdvancedSectionProps) => {
const values = useWatch({ control: form.control });
const preBackupWebhookBody = values.preBackupWebhookBody?.trim();
const postBackupWebhookBody = values.postBackupWebhookBody?.trim();
return (
<>
<FormField
@ -74,18 +78,22 @@ export const AdvancedSection = ({ form }: AdvancedSectionProps) => {
/>
<FormField
control={form.control}
name="preBackupWebhookHeadersText"
name="preBackupWebhookHeaders"
render={({ field }) => (
<FormItem>
<FormLabel>Pre-backup webhook headers</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder={'{\n "Authorization": "Bearer token"\n}'}
placeholder="Authorization: Bearer token&#10;X-Custom-Header: value"
value={Array.isArray(field.value) ? field.value.join("\n") : ""}
onChange={(e) => field.onChange(e.target.value.split("\n"))}
className="font-mono text-sm min-h-24"
/>
</FormControl>
<FormDescription>Optional JSON object. Values are stored as plain text.</FormDescription>
<FormDescription>
One header per line in Key: Value format. Values are stored as plain text.
</FormDescription>
<FormMessage />
</FormItem>
)}
@ -122,18 +130,22 @@ export const AdvancedSection = ({ form }: AdvancedSectionProps) => {
/>
<FormField
control={form.control}
name="postBackupWebhookHeadersText"
name="postBackupWebhookHeaders"
render={({ field }) => (
<FormItem>
<FormLabel>Post-backup webhook headers</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder={'{\n "Authorization": "Bearer token"\n}'}
placeholder="Authorization: Bearer token&#10;X-Custom-Header: value"
value={Array.isArray(field.value) ? field.value.join("\n") : ""}
onChange={(e) => field.onChange(e.target.value.split("\n"))}
className="font-mono text-sm min-h-24"
/>
</FormControl>
<FormDescription>Optional JSON object. Values are stored as plain text.</FormDescription>
<FormDescription>
One header per line in Key: Value format. Values are stored as plain text.
</FormDescription>
<FormMessage />
</FormItem>
)}

View file

@ -47,10 +47,10 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
maxRetries,
retryDelay,
preBackupWebhookUrl,
preBackupWebhookHeadersText,
preBackupWebhookHeaders,
preBackupWebhookBody,
postBackupWebhookUrl,
postBackupWebhookHeadersText,
postBackupWebhookHeaders,
postBackupWebhookBody,
...rest
} = data;
@ -74,14 +74,14 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
preBackupWebhook: preBackupWebhookUrlValue
? {
url: preBackupWebhookUrlValue,
headers: parseWebhookHeaders(preBackupWebhookHeadersText),
headers: parseWebhookHeaders(preBackupWebhookHeaders),
body: preBackupWebhookBodyValue || undefined,
}
: null,
postBackupWebhook: postBackupWebhookUrlValue
? {
url: postBackupWebhookUrlValue,
headers: parseWebhookHeaders(postBackupWebhookHeadersText),
headers: parseWebhookHeaders(postBackupWebhookHeaders),
body: postBackupWebhookBodyValue || undefined,
}
: null,

View file

@ -1,29 +1,13 @@
import { z } from "zod";
const webhookHeadersTextSchema = z
.string()
.optional()
.refine(
(value) => {
const trimmed = value?.trim();
if (!trimmed) {
return true;
}
try {
const parsed = JSON.parse(trimmed);
return (
parsed &&
typeof parsed === "object" &&
!Array.isArray(parsed) &&
Object.values(parsed).every((headerValue) => typeof headerValue === "string")
);
} catch {
return false;
}
},
{ message: "Headers must be a JSON object with string values" },
);
const webhookHeadersSchema = z
.array(
z.string().refine((header) => !header.trim() || header.includes(":"), {
message: "Headers must use Key: Value format",
}),
)
.catch(() => [])
.optional();
export const internalFormSchema = z.object({
name: z.string().min(1).max(128),
@ -46,10 +30,10 @@ export const internalFormSchema = z.object({
oneFileSystem: z.boolean().optional(),
customResticParamsText: z.string().optional(),
preBackupWebhookUrl: z.union([z.string().url(), z.literal("")]).optional(),
preBackupWebhookHeadersText: webhookHeadersTextSchema,
preBackupWebhookHeaders: webhookHeadersSchema,
preBackupWebhookBody: z.string().optional(),
postBackupWebhookUrl: z.union([z.string().url(), z.literal("")]).optional(),
postBackupWebhookHeadersText: webhookHeadersTextSchema,
postBackupWebhookHeaders: webhookHeadersSchema,
postBackupWebhookBody: z.string().optional(),
maxRetries: z.number().min(0).max(32).optional(),
retryDelay: z.number().min(1).max(1440).optional(),
@ -74,16 +58,16 @@ export type BackupScheduleFormValues = Omit<
| "includePatterns"
| "customResticParamsText"
| "preBackupWebhookUrl"
| "preBackupWebhookHeadersText"
| "preBackupWebhookHeaders"
| "preBackupWebhookBody"
| "postBackupWebhookUrl"
| "postBackupWebhookHeadersText"
| "postBackupWebhookHeaders"
| "postBackupWebhookBody"
> & {
excludePatterns?: string[];
excludeIfPresent?: string[];
includePatterns?: string[];
customResticParams?: string[];
preBackupWebhook?: { url: string; headers?: Record<string, string>; body?: string } | null;
postBackupWebhook?: { url: string; headers?: Record<string, string>; body?: string } | null;
preBackupWebhook?: { url: string; headers?: string[]; body?: string } | null;
postBackupWebhook?: { url: string; headers?: string[]; body?: string } | null;
};

View file

@ -10,21 +10,10 @@ export const parseMultilineEntries = (value?: string) =>
.filter(Boolean)
: [];
export const parseWebhookHeaders = (value?: string) => {
const trimmed = value?.trim();
if (!trimmed) {
return undefined;
}
export const parseWebhookHeaders = (headers?: string[]) => {
const parsedHeaders = headers?.map((header) => header.trim()).filter(Boolean) ?? [];
return JSON.parse(trimmed) as Record<string, string>;
};
const stringifyWebhookHeaders = (headers?: Record<string, string>) => {
if (!headers || Object.keys(headers).length === 0) {
return "";
}
return JSON.stringify(headers, null, 2);
return parsedHeaders.length > 0 ? parsedHeaders : undefined;
};
export const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValues | undefined => {
@ -44,10 +33,10 @@ export const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalF
oneFileSystem: schedule.oneFileSystem ?? false,
customResticParamsText: schedule.customResticParams?.join("\n") ?? "",
preBackupWebhookUrl: schedule.preBackupWebhook?.url ?? "",
preBackupWebhookHeadersText: stringifyWebhookHeaders(schedule.preBackupWebhook?.headers),
preBackupWebhookHeaders: schedule.preBackupWebhook?.headers ?? [],
preBackupWebhookBody: schedule.preBackupWebhook?.body ?? "",
postBackupWebhookUrl: schedule.postBackupWebhook?.url ?? "",
postBackupWebhookHeadersText: stringifyWebhookHeaders(schedule.postBackupWebhook?.headers),
postBackupWebhookHeaders: schedule.postBackupWebhook?.headers ?? [],
postBackupWebhookBody: schedule.postBackupWebhook?.body ?? "",
maxRetries: schedule.maxRetries,
retryDelay: schedule.retryDelay,

View file

@ -133,7 +133,7 @@ test("submits webhook headers and body as plain config values", async () => {
await userEvent.click(await screen.findByText("Advanced"));
await userEvent.type(screen.getByLabelText("Pre-backup webhook"), "http://localhost:8080/stop");
fireEvent.change(screen.getByLabelText("Pre-backup webhook headers"), {
target: { value: '{ "Authorization": "Bearer stop-token" }' },
target: { value: "Authorization: Bearer stop-token" },
});
fireEvent.change(screen.getByLabelText("Pre-backup webhook body"), {
target: { value: '{"action":"stop"}' },
@ -143,7 +143,7 @@ test("submits webhook headers and body as plain config values", async () => {
await expect(submittedBody).resolves.toMatchObject({
preBackupWebhook: {
url: "http://localhost:8080/stop",
headers: { Authorization: "Bearer stop-token" },
headers: ["Authorization: Bearer stop-token"],
body: '{"action":"stop"}',
},
});

View file

@ -4,8 +4,7 @@ import { Input } from "~/client/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Checkbox } from "~/client/components/ui/checkbox";
import { Textarea } from "~/client/components/ui/textarea";
import { CodeBlock } from "~/client/components/ui/code-block";
import { Label } from "~/client/components/ui/label";
import { WebhookRequestPreview } from "~/client/components/webhook-request-preview";
import type { NotificationFormValues } from "../create-notification-form";
type Props = {
@ -35,16 +34,14 @@ const WebhookPreview = ({ values }: { values: Partial<NotificationFormValues> })
body = "Notification message";
}
const previewCode = `${values.method} ${values.url}\nContent-Type: ${contentType}${headers.length > 0 ? `\n${headers.join("\n")}` : ""}
${body}`;
return (
<div className="space-y-2 pt-4 border-t">
<Label>Request Preview</Label>
<CodeBlock code={previewCode} filename="HTTP Request" />
<p className="text-[0.8rem] text-muted-foreground">This is a preview of the HTTP request that will be sent.</p>
</div>
<WebhookRequestPreview
method={values.method || "POST"}
url={values.url}
contentType={contentType}
headers={headers}
body={body}
/>
);
};

View file

@ -285,12 +285,12 @@ describe("backup execution - validation failures", () => {
repositoryId: repository.id,
preBackupWebhook: {
url: "http://localhost:8080/stop",
headers: { authorization: "Bearer stop-token" },
headers: ["authorization: Bearer stop-token"],
body: '{"action":"stop"}',
},
postBackupWebhook: {
url: "http://localhost:8080/start",
headers: { authorization: "Bearer start-token" },
headers: ["authorization: Bearer start-token"],
body: '{"action":"start"}',
},
});
@ -304,12 +304,12 @@ describe("backup execution - validation failures", () => {
webhooks: {
pre: {
url: "http://localhost:8080/stop",
headers: { authorization: "Bearer stop-token" },
headers: ["authorization: Bearer stop-token"],
body: '{"action":"stop"}',
},
post: {
url: "http://localhost:8080/start",
headers: { authorization: "Bearer start-token" },
headers: ["authorization: Bearer start-token"],
body: '{"action":"start"}',
},
},

View file

@ -416,12 +416,12 @@ describe("schedule webhooks", () => {
cronExpression: "0 0 * * *",
preBackupWebhook: {
url: "http://localhost:8080/stop",
headers: { authorization: "Bearer stop-token" },
headers: ["authorization: Bearer stop-token"],
body: '{"action":"stop"}',
},
postBackupWebhook: {
url: "http://localhost:8080/start",
headers: { authorization: "Bearer start-token" },
headers: ["authorization: Bearer start-token"],
body: '{"action":"start"}',
},
retryDelay: 15 * 60 * 1000,
@ -430,12 +430,12 @@ describe("schedule webhooks", () => {
expect(schedule.preBackupWebhook).toEqual({
url: "http://localhost:8080/stop",
headers: { authorization: "Bearer stop-token" },
headers: ["authorization: Bearer stop-token"],
body: '{"action":"stop"}',
});
expect(schedule.postBackupWebhook).toEqual({
url: "http://localhost:8080/start",
headers: { authorization: "Bearer start-token" },
headers: ["authorization: Bearer start-token"],
body: '{"action":"start"}',
});
});

View file

@ -2,11 +2,13 @@ import { Hono } from "hono";
import { validator } from "hono-openapi";
import {
createBackupScheduleBody,
createBackupScheduleResponse,
createBackupScheduleDto,
deleteBackupScheduleDto,
getBackupScheduleDto,
getBackupScheduleResponse,
getBackupScheduleForVolumeDto,
getBackupScheduleForVolumeResponse,
listBackupSchedulesDto,
listBackupSchedulesResponse,
runBackupNowDto,
@ -14,6 +16,7 @@ import {
stopBackupDto,
updateBackupScheduleDto,
updateBackupScheduleBody,
updateBackupScheduleResponse,
getScheduleMirrorsDto,
updateScheduleMirrorsDto,
updateScheduleMirrorsBody,
@ -73,20 +76,20 @@ export const backupScheduleController = new Hono()
const volumeShortId = asShortId(c.req.param("volumeShortId"));
const schedule = await backupsService.getScheduleForVolume(volumeShortId);
return c.json<GetBackupScheduleForVolumeResponseDto>(schedule, 200);
return c.json<GetBackupScheduleForVolumeResponseDto>(getBackupScheduleForVolumeResponse.parse(schedule), 200);
})
.post("/", createBackupScheduleDto, validator("json", createBackupScheduleBody), async (c) => {
const body = c.req.valid("json");
const schedule = await backupsService.createSchedule(body);
return c.json<CreateBackupScheduleDto>(schedule, 201);
return c.json<CreateBackupScheduleDto>(createBackupScheduleResponse.parse(schedule), 201);
})
.patch("/:shortId", updateBackupScheduleDto, validator("json", updateBackupScheduleBody), async (c) => {
const shortId = asShortId(c.req.param("shortId"));
const body = c.req.valid("json");
const schedule = await backupsService.updateSchedule(shortId, body);
return c.json<UpdateBackupScheduleDto>(schedule, 200);
return c.json<UpdateBackupScheduleDto>(updateBackupScheduleResponse.parse(schedule), 200);
})
.delete("/:shortId", deleteBackupScheduleDto, async (c) => {
const shortId = asShortId(c.req.param("shortId"));

View file

@ -95,7 +95,7 @@ export const getBackupScheduleDto = describeRoute({
},
});
const getBackupScheduleForVolumeResponse = backupScheduleSchema.nullable();
export const getBackupScheduleForVolumeResponse = backupScheduleSchema.nullable();
export type GetBackupScheduleForVolumeResponseDto = z.infer<typeof getBackupScheduleForVolumeResponse>;
@ -143,7 +143,7 @@ export const createBackupScheduleBody = z.object({
export type CreateBackupScheduleBody = z.infer<typeof createBackupScheduleBody>;
const createBackupScheduleResponse = backupScheduleSchema.omit({ volume: true, repository: true });
export const createBackupScheduleResponse = backupScheduleSchema.omit({ volume: true, repository: true });
export type CreateBackupScheduleDto = z.infer<typeof createBackupScheduleResponse>;
@ -190,7 +190,7 @@ export const updateBackupScheduleBody = z.object({
export type UpdateBackupScheduleBody = z.infer<typeof updateBackupScheduleBody>;
const updateBackupScheduleResponse = backupScheduleSchema.omit({ volume: true, repository: true });
export const updateBackupScheduleResponse = backupScheduleSchema.omit({ volume: true, repository: true });
export type UpdateBackupScheduleDto = z.infer<typeof updateBackupScheduleResponse>;

View file

@ -140,12 +140,12 @@ test("sends configured webhook headers and body without changing them", async ()
webhooks: {
pre: {
url: "http://localhost:8080/pre",
headers: { authorization: "Bearer pre-token", "content-type": "application/json" },
headers: ["authorization: Bearer pre-token", "content-type: application/json"],
body: '{"action":"stop"}',
},
post: {
url: "http://localhost:8080/post",
headers: { authorization: "Bearer post-token" },
headers: ["authorization: Bearer post-token"],
body: "start-container",
},
},
@ -164,7 +164,10 @@ test("sends configured webhook headers and body without changing them", async ()
test("fails without running restic when the pre-backup webhook fails", async () => {
const backupMock = vi.fn();
vi.stubGlobal("fetch", vi.fn(async () => new Response("stop failed", { status: 500 })));
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("stop failed", { status: 500 })),
);
vi.spyOn(resticServer, "createRestic").mockReturnValue(
fromPartial({
backup: backupMock,
@ -189,7 +192,10 @@ test("fails without running restic when the pre-backup webhook fails", async ()
});
test("reports a post-backup webhook failure as completed warning details", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response("start failed", { status: 500 })));
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("start failed", { status: 500 })),
);
vi.spyOn(resticServer, "createRestic").mockReturnValue(
fromPartial({
backup: () => Effect.succeed({ exitCode: 0, result: null, warningDetails: null }),

View file

@ -198,7 +198,7 @@ test("sends configured webhook headers and body without replacing them", async (
pre: null,
post: {
url: "http://localhost:8080/post",
headers: { authorization: "Bearer post-token" },
headers: ["authorization: Bearer post-token"],
body: "start-container",
},
},

View file

@ -6,7 +6,10 @@ export const DEFAULT_BACKUP_WEBHOOK_TIMEOUT_MS = 60_000;
export const backupWebhookConfigSchema = z.object({
url: z.url(),
headers: z.record(z.string(), z.string()).optional(),
headers: z
.array(z.string())
.catch(() => [])
.optional(),
body: z.string().optional(),
});
@ -138,8 +141,12 @@ const createRequestInit = (config: BackupWebhookConfig, context: BackupWebhookCo
headers.set("content-type", "application/json");
}
for (const [name, value] of Object.entries(config.headers ?? {})) {
headers.set(name, value);
for (const header of config.headers ?? []) {
const [name, ...valueParts] = header.split(":");
if (name && valueParts.length > 0) {
headers.set(name.trim(), valueParts.join(":").trim());
}
}
return {