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; customResticParams: Array<string> | null;
preBackupWebhook: { preBackupWebhook: {
url: string; url: string;
headers?: Record<string, string>; headers?: Array<string>;
body?: string; body?: string;
} | null; } | null;
postBackupWebhook: { postBackupWebhook: {
url: string; url: string;
headers?: Record<string, string>; headers?: Array<string>;
body?: string; body?: string;
} | null; } | null;
maxRetries: number; maxRetries: number;
@ -2724,12 +2724,12 @@ export type CreateBackupScheduleData = {
customResticParams?: Array<string>; customResticParams?: Array<string>;
preBackupWebhook?: { preBackupWebhook?: {
url: string; url: string;
headers?: Record<string, string>; headers?: Array<string>;
body?: string; body?: string;
} | null; } | null;
postBackupWebhook?: { postBackupWebhook?: {
url: string; url: string;
headers?: Record<string, string>; headers?: Array<string>;
body?: string; body?: string;
} | null; } | null;
maxRetries?: number; maxRetries?: number;
@ -2769,12 +2769,12 @@ export type CreateBackupScheduleResponses = {
customResticParams: Array<string> | null; customResticParams: Array<string> | null;
preBackupWebhook: { preBackupWebhook: {
url: string; url: string;
headers?: Record<string, string>; headers?: Array<string>;
body?: string; body?: string;
} | null; } | null;
postBackupWebhook: { postBackupWebhook: {
url: string; url: string;
headers?: Record<string, string>; headers?: Array<string>;
body?: string; body?: string;
} | null; } | null;
maxRetries: number; maxRetries: number;
@ -2848,12 +2848,12 @@ export type GetBackupScheduleResponses = {
customResticParams: Array<string> | null; customResticParams: Array<string> | null;
preBackupWebhook: { preBackupWebhook: {
url: string; url: string;
headers?: Record<string, string>; headers?: Array<string>;
body?: string; body?: string;
} | null; } | null;
postBackupWebhook: { postBackupWebhook: {
url: string; url: string;
headers?: Record<string, string>; headers?: Array<string>;
body?: string; body?: string;
} | null; } | null;
maxRetries: number; maxRetries: number;
@ -3135,12 +3135,12 @@ export type UpdateBackupScheduleData = {
customResticParams?: Array<string>; customResticParams?: Array<string>;
preBackupWebhook?: { preBackupWebhook?: {
url: string; url: string;
headers?: Record<string, string>; headers?: Array<string>;
body?: string; body?: string;
} | null; } | null;
postBackupWebhook?: { postBackupWebhook?: {
url: string; url: string;
headers?: Record<string, string>; headers?: Array<string>;
body?: string; body?: string;
} | null; } | null;
maxRetries?: number; maxRetries?: number;
@ -3182,12 +3182,12 @@ export type UpdateBackupScheduleResponses = {
customResticParams: Array<string> | null; customResticParams: Array<string> | null;
preBackupWebhook: { preBackupWebhook: {
url: string; url: string;
headers?: Record<string, string>; headers?: Array<string>;
body?: string; body?: string;
} | null; } | null;
postBackupWebhook: { postBackupWebhook: {
url: string; url: string;
headers?: Record<string, string>; headers?: Array<string>;
body?: string; body?: string;
} | null; } | null;
maxRetries: number; maxRetries: number;
@ -3241,12 +3241,12 @@ export type GetBackupScheduleForVolumeResponses = {
customResticParams: Array<string> | null; customResticParams: Array<string> | null;
preBackupWebhook: { preBackupWebhook: {
url: string; url: string;
headers?: Record<string, string>; headers?: Array<string>;
body?: string; body?: string;
} | null; } | null;
postBackupWebhook: { postBackupWebhook: {
url: string; url: string;
headers?: Record<string, string>; headers?: Array<string>;
body?: string; body?: string;
} | null; } | null;
maxRetries: number; 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 { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
import { Textarea } from "~/client/components/ui/textarea"; 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 type { InternalFormValues } from "./types";
import { Input } from "~/client/components/ui/input"; import { Input } from "~/client/components/ui/input";
@ -9,6 +9,10 @@ type AdvancedSectionProps = {
}; };
export const AdvancedSection = ({ form }: AdvancedSectionProps) => { export const AdvancedSection = ({ form }: AdvancedSectionProps) => {
const values = useWatch({ control: form.control });
const preBackupWebhookBody = values.preBackupWebhookBody?.trim();
const postBackupWebhookBody = values.postBackupWebhookBody?.trim();
return ( return (
<> <>
<FormField <FormField
@ -74,18 +78,22 @@ export const AdvancedSection = ({ form }: AdvancedSectionProps) => {
/> />
<FormField <FormField
control={form.control} control={form.control}
name="preBackupWebhookHeadersText" name="preBackupWebhookHeaders"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Pre-backup webhook headers</FormLabel> <FormLabel>Pre-backup webhook headers</FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea
{...field} {...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" className="font-mono text-sm min-h-24"
/> />
</FormControl> </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 /> <FormMessage />
</FormItem> </FormItem>
)} )}
@ -122,18 +130,22 @@ export const AdvancedSection = ({ form }: AdvancedSectionProps) => {
/> />
<FormField <FormField
control={form.control} control={form.control}
name="postBackupWebhookHeadersText" name="postBackupWebhookHeaders"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Post-backup webhook headers</FormLabel> <FormLabel>Post-backup webhook headers</FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea
{...field} {...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" className="font-mono text-sm min-h-24"
/> />
</FormControl> </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 /> <FormMessage />
</FormItem> </FormItem>
)} )}

View file

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

View file

@ -1,29 +1,13 @@
import { z } from "zod"; import { z } from "zod";
const webhookHeadersTextSchema = z const webhookHeadersSchema = z
.string() .array(
.optional() z.string().refine((header) => !header.trim() || header.includes(":"), {
.refine( message: "Headers must use Key: Value format",
(value) => { }),
const trimmed = value?.trim(); )
if (!trimmed) { .catch(() => [])
return true; .optional();
}
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" },
);
export const internalFormSchema = z.object({ export const internalFormSchema = z.object({
name: z.string().min(1).max(128), name: z.string().min(1).max(128),
@ -46,10 +30,10 @@ export const internalFormSchema = z.object({
oneFileSystem: z.boolean().optional(), oneFileSystem: z.boolean().optional(),
customResticParamsText: z.string().optional(), customResticParamsText: z.string().optional(),
preBackupWebhookUrl: z.union([z.string().url(), z.literal("")]).optional(), preBackupWebhookUrl: z.union([z.string().url(), z.literal("")]).optional(),
preBackupWebhookHeadersText: webhookHeadersTextSchema, preBackupWebhookHeaders: webhookHeadersSchema,
preBackupWebhookBody: z.string().optional(), preBackupWebhookBody: z.string().optional(),
postBackupWebhookUrl: z.union([z.string().url(), z.literal("")]).optional(), postBackupWebhookUrl: z.union([z.string().url(), z.literal("")]).optional(),
postBackupWebhookHeadersText: webhookHeadersTextSchema, postBackupWebhookHeaders: webhookHeadersSchema,
postBackupWebhookBody: z.string().optional(), postBackupWebhookBody: z.string().optional(),
maxRetries: z.number().min(0).max(32).optional(), maxRetries: z.number().min(0).max(32).optional(),
retryDelay: z.number().min(1).max(1440).optional(), retryDelay: z.number().min(1).max(1440).optional(),
@ -74,16 +58,16 @@ export type BackupScheduleFormValues = Omit<
| "includePatterns" | "includePatterns"
| "customResticParamsText" | "customResticParamsText"
| "preBackupWebhookUrl" | "preBackupWebhookUrl"
| "preBackupWebhookHeadersText" | "preBackupWebhookHeaders"
| "preBackupWebhookBody" | "preBackupWebhookBody"
| "postBackupWebhookUrl" | "postBackupWebhookUrl"
| "postBackupWebhookHeadersText" | "postBackupWebhookHeaders"
| "postBackupWebhookBody" | "postBackupWebhookBody"
> & { > & {
excludePatterns?: string[]; excludePatterns?: string[];
excludeIfPresent?: string[]; excludeIfPresent?: string[];
includePatterns?: string[]; includePatterns?: string[];
customResticParams?: string[]; customResticParams?: string[];
preBackupWebhook?: { url: string; headers?: Record<string, string>; body?: string } | null; preBackupWebhook?: { url: string; headers?: string[]; body?: string } | null;
postBackupWebhook?: { url: string; headers?: Record<string, 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) .filter(Boolean)
: []; : [];
export const parseWebhookHeaders = (value?: string) => { export const parseWebhookHeaders = (headers?: string[]) => {
const trimmed = value?.trim(); const parsedHeaders = headers?.map((header) => header.trim()).filter(Boolean) ?? [];
if (!trimmed) {
return undefined;
}
return JSON.parse(trimmed) as Record<string, string>; return parsedHeaders.length > 0 ? parsedHeaders : undefined;
};
const stringifyWebhookHeaders = (headers?: Record<string, string>) => {
if (!headers || Object.keys(headers).length === 0) {
return "";
}
return JSON.stringify(headers, null, 2);
}; };
export const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValues | undefined => { export const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValues | undefined => {
@ -44,10 +33,10 @@ export const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalF
oneFileSystem: schedule.oneFileSystem ?? false, oneFileSystem: schedule.oneFileSystem ?? false,
customResticParamsText: schedule.customResticParams?.join("\n") ?? "", customResticParamsText: schedule.customResticParams?.join("\n") ?? "",
preBackupWebhookUrl: schedule.preBackupWebhook?.url ?? "", preBackupWebhookUrl: schedule.preBackupWebhook?.url ?? "",
preBackupWebhookHeadersText: stringifyWebhookHeaders(schedule.preBackupWebhook?.headers), preBackupWebhookHeaders: schedule.preBackupWebhook?.headers ?? [],
preBackupWebhookBody: schedule.preBackupWebhook?.body ?? "", preBackupWebhookBody: schedule.preBackupWebhook?.body ?? "",
postBackupWebhookUrl: schedule.postBackupWebhook?.url ?? "", postBackupWebhookUrl: schedule.postBackupWebhook?.url ?? "",
postBackupWebhookHeadersText: stringifyWebhookHeaders(schedule.postBackupWebhook?.headers), postBackupWebhookHeaders: schedule.postBackupWebhook?.headers ?? [],
postBackupWebhookBody: schedule.postBackupWebhook?.body ?? "", postBackupWebhookBody: schedule.postBackupWebhook?.body ?? "",
maxRetries: schedule.maxRetries, maxRetries: schedule.maxRetries,
retryDelay: schedule.retryDelay, 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.click(await screen.findByText("Advanced"));
await userEvent.type(screen.getByLabelText("Pre-backup webhook"), "http://localhost:8080/stop"); await userEvent.type(screen.getByLabelText("Pre-backup webhook"), "http://localhost:8080/stop");
fireEvent.change(screen.getByLabelText("Pre-backup webhook headers"), { 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"), { fireEvent.change(screen.getByLabelText("Pre-backup webhook body"), {
target: { value: '{"action":"stop"}' }, target: { value: '{"action":"stop"}' },
@ -143,7 +143,7 @@ test("submits webhook headers and body as plain config values", async () => {
await expect(submittedBody).resolves.toMatchObject({ await expect(submittedBody).resolves.toMatchObject({
preBackupWebhook: { preBackupWebhook: {
url: "http://localhost:8080/stop", url: "http://localhost:8080/stop",
headers: { Authorization: "Bearer stop-token" }, headers: ["Authorization: Bearer stop-token"],
body: '{"action":"stop"}', 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Checkbox } from "~/client/components/ui/checkbox"; import { Checkbox } from "~/client/components/ui/checkbox";
import { Textarea } from "~/client/components/ui/textarea"; import { Textarea } from "~/client/components/ui/textarea";
import { CodeBlock } from "~/client/components/ui/code-block"; import { WebhookRequestPreview } from "~/client/components/webhook-request-preview";
import { Label } from "~/client/components/ui/label";
import type { NotificationFormValues } from "../create-notification-form"; import type { NotificationFormValues } from "../create-notification-form";
type Props = { type Props = {
@ -35,16 +34,14 @@ const WebhookPreview = ({ values }: { values: Partial<NotificationFormValues> })
body = "Notification message"; body = "Notification message";
} }
const previewCode = `${values.method} ${values.url}\nContent-Type: ${contentType}${headers.length > 0 ? `\n${headers.join("\n")}` : ""}
${body}`;
return ( return (
<div className="space-y-2 pt-4 border-t"> <WebhookRequestPreview
<Label>Request Preview</Label> method={values.method || "POST"}
<CodeBlock code={previewCode} filename="HTTP Request" /> url={values.url}
<p className="text-[0.8rem] text-muted-foreground">This is a preview of the HTTP request that will be sent.</p> contentType={contentType}
</div> headers={headers}
body={body}
/>
); );
}; };

View file

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

View file

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

View file

@ -2,11 +2,13 @@ import { Hono } from "hono";
import { validator } from "hono-openapi"; import { validator } from "hono-openapi";
import { import {
createBackupScheduleBody, createBackupScheduleBody,
createBackupScheduleResponse,
createBackupScheduleDto, createBackupScheduleDto,
deleteBackupScheduleDto, deleteBackupScheduleDto,
getBackupScheduleDto, getBackupScheduleDto,
getBackupScheduleResponse, getBackupScheduleResponse,
getBackupScheduleForVolumeDto, getBackupScheduleForVolumeDto,
getBackupScheduleForVolumeResponse,
listBackupSchedulesDto, listBackupSchedulesDto,
listBackupSchedulesResponse, listBackupSchedulesResponse,
runBackupNowDto, runBackupNowDto,
@ -14,6 +16,7 @@ import {
stopBackupDto, stopBackupDto,
updateBackupScheduleDto, updateBackupScheduleDto,
updateBackupScheduleBody, updateBackupScheduleBody,
updateBackupScheduleResponse,
getScheduleMirrorsDto, getScheduleMirrorsDto,
updateScheduleMirrorsDto, updateScheduleMirrorsDto,
updateScheduleMirrorsBody, updateScheduleMirrorsBody,
@ -73,20 +76,20 @@ export const backupScheduleController = new Hono()
const volumeShortId = asShortId(c.req.param("volumeShortId")); const volumeShortId = asShortId(c.req.param("volumeShortId"));
const schedule = await backupsService.getScheduleForVolume(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) => { .post("/", createBackupScheduleDto, validator("json", createBackupScheduleBody), async (c) => {
const body = c.req.valid("json"); const body = c.req.valid("json");
const schedule = await backupsService.createSchedule(body); 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) => { .patch("/:shortId", updateBackupScheduleDto, validator("json", updateBackupScheduleBody), async (c) => {
const shortId = asShortId(c.req.param("shortId")); const shortId = asShortId(c.req.param("shortId"));
const body = c.req.valid("json"); const body = c.req.valid("json");
const schedule = await backupsService.updateSchedule(shortId, body); 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) => { .delete("/:shortId", deleteBackupScheduleDto, async (c) => {
const shortId = asShortId(c.req.param("shortId")); 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>; export type GetBackupScheduleForVolumeResponseDto = z.infer<typeof getBackupScheduleForVolumeResponse>;
@ -143,7 +143,7 @@ export const createBackupScheduleBody = z.object({
export type CreateBackupScheduleBody = z.infer<typeof createBackupScheduleBody>; 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>; export type CreateBackupScheduleDto = z.infer<typeof createBackupScheduleResponse>;
@ -190,7 +190,7 @@ export const updateBackupScheduleBody = z.object({
export type UpdateBackupScheduleBody = z.infer<typeof updateBackupScheduleBody>; 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>; 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: { webhooks: {
pre: { pre: {
url: "http://localhost:8080/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"}', body: '{"action":"stop"}',
}, },
post: { post: {
url: "http://localhost:8080/post", url: "http://localhost:8080/post",
headers: { authorization: "Bearer post-token" }, headers: ["authorization: Bearer post-token"],
body: "start-container", 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 () => { test("fails without running restic when the pre-backup webhook fails", async () => {
const backupMock = vi.fn(); 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( vi.spyOn(resticServer, "createRestic").mockReturnValue(
fromPartial({ fromPartial({
backup: backupMock, 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 () => { 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( vi.spyOn(resticServer, "createRestic").mockReturnValue(
fromPartial({ fromPartial({
backup: () => Effect.succeed({ exitCode: 0, result: null, warningDetails: null }), 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, pre: null,
post: { post: {
url: "http://localhost:8080/post", url: "http://localhost:8080/post",
headers: { authorization: "Bearer post-token" }, headers: ["authorization: Bearer post-token"],
body: "start-container", body: "start-container",
}, },
}, },

View file

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