feat: modernize dashboard widgets

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-13 19:54:28 +02:00
1 parent 1a5e083fd0
commit 6f2cd7fc2b
270 files changed
+10189 -3544

No files matched your search

@@ -101,9 +101,7 @@ describe("patchmonRouter.getStats access control", () => {
});
const caller = createCaller(db, null);
await expect(caller.getStats({ integrationId })).rejects.toThrow(
new TRPCError({ code: "UNAUTHORIZED" }),
);
await expect(caller.getStats({ integrationId })).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED" }));
});
test("should throw FORBIDDEN for authenticated users without board or integration access", async () => {
@@ -0,0 +1,114 @@
import { stringify } from "superjson";
import { describe, expect, it, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database } from "@homarr/db";
import { boards, items, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { timetableRouter } from "../../widgets/timetable";
const mocks = vi.hoisted(() => ({ getTimetable: vi.fn(), searchStations: vi.fn() }));
vi.mock("@homarr/request-handler/timetable", () => ({
timetableGetTimetableRequestHandler: {
handler: (input: unknown) => ({
getDataAsync: async () => {
mocks.getTimetable(input);
return { data: { stationId: "station", timestamp: new Date(0), entries: [] } };
},
}),
},
timetableSearchStationsRequestHandler: {
handler: (input: unknown) => ({
getDataAsync: async () => {
mocks.searchStations(input);
return { data: [] };
},
}),
},
}));
const createSession = (userId: string): Session => ({
user: { id: userId, permissions: [], colorScheme: "light" },
expires: new Date().toISOString(),
});
const createCaller = (db: Database, session: Session | null) =>
timetableRouter.createCaller({ db, deviceType: undefined, session });
const createTimetableItemAsync = async (
db: Database,
{ baseUrl, isPublic = true }: { baseUrl: string; isPublic?: boolean },
) => {
const ownerId = createId();
const boardId = createId();
const itemId = createId();
await db.insert(users).values({ id: ownerId });
await db.insert(boards).values({ id: boardId, name: `board-${boardId}`, creatorId: ownerId, isPublic });
await db.insert(items).values({
id: itemId,
boardId,
kind: "timetable",
options: stringify({ baseUrl }),
});
return { boardId, itemId, ownerId };
};
describe("timetableRouter source authorization", () => {
it("allows itemless setup only for the fixed default provider", async () => {
const caller = createCaller(createDb(), null);
await expect(caller.searchStations({ baseUrl: "https://search.ch", query: "Bern" })).resolves.toEqual([]);
await expect(caller.searchStations({ baseUrl: "http://127.0.0.1:8080", query: "Bern" })).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
it("loads a persisted private endpoint only through a viewable timetable item", async () => {
const db = createDb();
const baseUrl = "http://timetable.internal:8080";
const { itemId } = await createTimetableItemAsync(db, { baseUrl });
const caller = createCaller(db, null);
await caller.getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 });
expect(mocks.getTimetable).toHaveBeenLastCalledWith(expect.objectContaining({ baseUrl }));
await expect(
caller.getTimetable({ baseUrl: "http://127.0.0.1:8080", itemId, stationId: "station", limit: 10 }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("rejects a persisted endpoint when its board is not viewable", async () => {
const db = createDb();
const baseUrl = "http://timetable.internal:8080";
const { itemId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false });
await expect(
createCaller(db, null).getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("allows board modifiers to search an unsaved self-hosted endpoint", async () => {
const db = createDb();
const baseUrl = "http://timetable.internal:8080";
const { boardId, ownerId } = await createTimetableItemAsync(db, { baseUrl: "https://search.ch", isPublic: false });
const caller = createCaller(db, createSession(ownerId));
await caller.searchStations({ baseUrl, boardId, query: "Bern" });
expect(mocks.searchStations).toHaveBeenLastCalledWith(expect.objectContaining({ baseUrl }));
});
it("does not treat public board view access as configuration access", async () => {
const db = createDb();
const baseUrl = "http://timetable.internal:8080";
const { boardId, itemId } = await createTimetableItemAsync(db, { baseUrl });
await expect(createCaller(db, null).searchStations({ baseUrl, boardId, query: "Bern" })).rejects.toMatchObject({
code: "NOT_FOUND",
});
await expect(createCaller(db, null).searchStations({ baseUrl, itemId, query: "Bern" })).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
});
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { everyBeszelIntegrationFailed } from "../beszel-failures";
describe("everyBeszelIntegrationFailed", () => {
it("keeps partial successes", () => {
expect(everyBeszelIntegrationFailed([{ error: "offline" }, {}])).toBe(false);
});
it("detects when every integration failed", () => {
expect(everyBeszelIntegrationFailed([{ error: "offline" }, { error: "timeout" }])).toBe(true);
});
it("allows an empty integration selection", () => {
expect(everyBeszelIntegrationFailed([])).toBe(false);
});
});
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { readBoundedJsonResponseAsync, validateCustomApiUrl } from "../custom-api-security";
describe("custom API request hardening", () => {
it("accepts HTTP endpoints without embedded credentials", () => {
expect(validateCustomApiUrl("https://api.example.com/v1/data").hostname).toBe("api.example.com");
});
it.each(["file:///etc/passwd", "https://user:secret@example.com/data"])("rejects unsafe URL %s", (url) => {
expect(() => validateCustomApiUrl(url)).toThrow();
});
it("rejects a response once its byte budget is exceeded", async () => {
const response = new Response(JSON.stringify({ value: "x".repeat(100) }));
await expect(readBoundedJsonResponseAsync(response, 32)).rejects.toThrow("exceeds 32 bytes");
});
});
@@ -0,0 +1,5 @@
export const everyBeszelIntegrationFailed = (results: readonly unknown[]) =>
results.length > 0 &&
results.every(
(result) => typeof result === "object" && result !== null && "error" in result && result.error !== undefined,
);
+17
View File
@@ -13,6 +13,7 @@ import {
import { settleIntegrationQueries } from "../../settle-integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { everyBeszelIntegrationFailed } from "./beszel-failures";
import { BoundedAsyncQueue } from "./bounded-async-queue";
const logger = createLogger({ module: "beszelRouter" });
@@ -20,6 +21,20 @@ const MAX_PENDING_LIVE_EVENTS = 4;
const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error));
function throwIfEveryBeszelIntegrationFailed(results: readonly unknown[], operation: string) {
if (everyBeszelIntegrationFailed(results)) {
const firstError = results[0];
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Every Beszel integration failed while loading ${operation}: ${
typeof firstError === "object" && firstError !== null && "error" in firstError
? String(firstError.error)
: "unknown error"
}`,
});
}
}
export const beszelRouter = createTRPCRouter({
getSystems: publicProcedure
.meta({
@@ -63,6 +78,7 @@ export const beszelRouter = createTRPCRouter({
resultCount: results.length,
errorCount: results.filter((r) => "error" in r).length,
});
throwIfEveryBeszelIntegrationFailed(results, "systems");
return results;
}),
@@ -136,6 +152,7 @@ export const beszelRouter = createTRPCRouter({
resultCount: results.length,
errorCount: results.filter((r) => "error" in r).length,
});
throwIfEveryBeszelIntegrationFailed(results, "alerts");
return results;
}),
+13 -36
View File
@@ -1,16 +1,13 @@
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { radarrReleaseTypes } from "@homarr/integrations/types";
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "calendarRouter" });
export const calendarRouter = createTRPCRouter({
findAllEvents: publicProcedure
.meta({
@@ -30,39 +27,19 @@ export const calendarRouter = createTRPCRouter({
)
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
.query(async ({ ctx, input }) => {
const settled = await Promise.allSettled(
ctx.integrations.map(async (integration) => {
const { integrationIds: _integrationIds, ...handlerInput } = input;
const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
const { data } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { integrationIds: _integrationIds, ...handlerInput } = input;
const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
const { data } = await innerHandler.getDataAsync();
return {
events: data,
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
},
};
}),
);
return settled.flatMap((result, index) => {
if (result.status === "fulfilled") {
return [result.value];
}
const integration = ctx.integrations[index];
logger.warn(
new ErrorWithMetadata(
"Calendar integration request failed; skipping events for this integration",
{
integrationId: integration?.id,
integrationKind: integration?.kind,
},
{ cause: result.reason },
),
);
return [];
return {
events: data,
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
},
};
});
}),
});
@@ -0,0 +1,21 @@
import { readBoundedJsonResponseAsync as readBoundedJsonResponse } from "@homarr/common/server";
export const MAX_CUSTOM_API_RESPONSE_BYTES = 2 * 1024 * 1024;
export function validateCustomApiUrl(urlString: string): URL {
if (urlString.length > 2_048) throw new Error("Custom API URL is too long");
const url = new URL(urlString);
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("Custom API URL must use HTTP or HTTPS");
}
if (url.username || url.password) throw new Error("Custom API URL must not contain credentials");
return url;
}
export async function readBoundedJsonResponseAsync(
response: Response,
maxBytes = MAX_CUSTOM_API_RESPONSE_BYTES,
): Promise<unknown> {
return await readBoundedJsonResponse(response, maxBytes, "Custom API response");
}
@@ -33,7 +33,6 @@ const namedRequestInputSchema = itemInputSchema.extend({
requestId: z.string().min(1).max(64),
params: runtimeParamsSchema.default({}),
});
interface CustomWidgetItemOptions {
definitionId: string;
configuration: Record<string, unknown>;
+24 -11
View File
@@ -18,20 +18,33 @@ const queryFirewall = <
},
>(
handler: THandler,
createFallback: () => Awaited<ReturnType<ReturnType<THandler["handler"]>["getDataAsync"]>>["data"],
) =>
publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) =>
settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data, timestamp } = await handler.handler(integration, {}).getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
summary: data as Awaited<ReturnType<ReturnType<THandler["handler"]>["getDataAsync"]>>["data"],
};
}),
settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data, timestamp } = await handler.handler(integration, {}).getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
summary: data as Awaited<ReturnType<ReturnType<THandler["handler"]>["getDataAsync"]>>["data"],
error: undefined as string | undefined,
};
},
{
fallback: (integration, error) => ({
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: new Date(0) },
summary: createFallback(),
error: error instanceof Error ? error.message : "Integration request failed",
}),
throwOnAllFailures: true,
},
),
);
export const firewallRouter = createTRPCRouter({
getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler),
getFirewallInterfacesStatus: queryFirewall(firewallInterfacesRequestHandler),
getFirewallVersionStatus: queryFirewall(firewallVersionRequestHandler),
getFirewallMemoryStatus: queryFirewall(firewallMemoryRequestHandler),
getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler, () => ({ total: 0 })),
getFirewallInterfacesStatus: queryFirewall(firewallInterfacesRequestHandler, () => []),
getFirewallVersionStatus: queryFirewall(firewallVersionRequestHandler, () => ({ version: "Unknown" })),
getFirewallMemoryStatus: queryFirewall(firewallMemoryRequestHandler, () => ({ used: 0, total: 0, percent: 0 })),
});
@@ -16,15 +16,29 @@ export const indexerManagerRouter = createTRPCRouter({
getIndexersStatus: publicProcedure
.concat(createIndexerManagerIntegrationMiddleware("query"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = indexerManagerRequestHandler.handler(integration, {});
const { data: indexers } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = indexerManagerRequestHandler.handler(integration, {});
const { data: indexers } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
indexers,
};
});
return {
integrationId: integration.id,
integrationName: integration.name,
indexers,
error: undefined as string | undefined,
};
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
indexers: [],
error: error instanceof Error ? error.message : String(error),
}),
throwOnAllFailures: true,
},
);
}),
testAllIndexers: protectedProcedure
.concat(createIndexerManagerIntegrationMiddleware("interact"))
@@ -4,6 +4,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaOrganizerRequestHandler } from "@homarr/request-handler/media-organizer";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const mediaOrganizerRouter = createTRPCRouter({
@@ -18,16 +19,30 @@ export const mediaOrganizerRouter = createTRPCRouter({
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaOrganizer")))
.input(z.object({ pageSize: z.number().min(1).max(50).default(10) }))
.query(async ({ ctx, input }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = mediaOrganizerRequestHandler.handler(integration, { pageSize: input.pageSize });
const { data } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
...data,
error: undefined as string | undefined,
};
}),
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
missing: [],
missingCount: 0,
queued: [],
queuedCount: 0,
error: error instanceof Error ? error.message : String(error),
}),
throwOnAllFailures: true,
},
);
return results;
}),
});
@@ -34,6 +34,7 @@ export const mediaRequestsRouter = createTRPCRouter({
data.map((request) => ({
...request,
integrationId: integration.id,
integration,
})),
)
.toSorted((dataA, dataB) => {
@@ -99,5 +100,6 @@ export const mediaRequestsRouter = createTRPCRouter({
} as const;
await answerActions[input.answer](input.requestId);
return { success: true };
}),
});
@@ -27,7 +27,12 @@ export const mediaServerRouter = createTRPCRouter({
const { data } = await mediaServerRequestHandler
.handler(integration, { showOnlyPlaying: input.showOnlyPlaying })
.getDataAsync();
return { integrationId: integration.id, integrationKind: integration.kind, sessions: data };
return {
integrationId: integration.id,
integrationName: integration.name,
integrationKind: integration.kind,
sessions: data,
};
});
}),
});
@@ -13,19 +13,36 @@ export const notificationsRouter = createTRPCRouter({
getNotifications: publicProcedure
.unstable_concat(createNotificationsIntegrationMiddleware("query"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = notificationsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = notificationsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
data,
};
});
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
data,
error: undefined as string | undefined,
};
},
{
fallback: (integration, error) => ({
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: new Date(0),
},
data: [],
error: error instanceof Error ? error.message : String(error),
}),
throwOnAllFailures: true,
},
);
}),
});
@@ -37,6 +37,11 @@ export const rssFeedRouter = createTRPCRouter({
}),
);
const failures = settled.filter((result) => result.status === "rejected");
if (settled.length > 0 && failures.length === settled.length) {
throw failures[0]?.reason;
}
return settled
.flatMap((result, index) => {
if (result.status === "fulfilled") return result.value.data.entries;
+30 -5
View File
@@ -1,8 +1,12 @@
import { z } from "zod/v4";
import { TRPCError } from "@trpc/server";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { smartHomeEntityStateRequestHandler } from "@homarr/request-handler/smart-home-entity-state";
import {
smartHomeEntityStateRequestHandler,
toSafeEntityDetails,
} from "@homarr/request-handler/smart-home-entity-state";
import type { IntegrationAction } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
@@ -25,7 +29,22 @@ export const smartHomeRouter = createTRPCRouter({
.query(async ({ ctx: { integration }, input }) => {
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
const { data } = await innerHandler.getDataAsync();
return data;
return data.state;
}),
entityDetails: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get the state, attributes, and update timestamps for a Home Assistant entity. REQUIRED: integrationId (Home Assistant integration ID), entityId (for example 'sensor.temperature')",
},
})
.input(z.object({ entityId: z.string() }))
.concat(createSmartHomeIntegrationMiddleware("query"))
.query(async ({ ctx: { integration }, input }) => {
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
const { data } = await innerHandler.getDataAsync();
return toSafeEntityDetails(data);
}),
switchEntity: protectedProcedure
.meta({
@@ -40,8 +59,10 @@ export const smartHomeRouter = createTRPCRouter({
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
const success = await client.triggerToggleAsync(input.entityId);
return success;
if (!success) {
throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not toggle the entity" });
}
return true;
}),
executeAutomation: protectedProcedure
.meta({
@@ -55,6 +76,10 @@ export const smartHomeRouter = createTRPCRouter({
.input(z.object({ automationId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
await client.triggerAutomationAsync(input.automationId);
const success = await client.triggerAutomationAsync(input.automationId);
if (!success) {
throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not execute the automation" });
}
return true;
}),
});
+65 -11
View File
@@ -1,36 +1,90 @@
import { TRPCError } from "@trpc/server";
import { parse } from "superjson";
import z from "zod";
import type { Session } from "@homarr/auth";
import type { Database } from "@homarr/db";
import { and, eq } from "@homarr/db";
import { boards, items } from "@homarr/db/schema";
import {
timetableGetTimetableRequestHandler,
timetableSearchStationsRequestHandler,
} from "@homarr/request-handler/timetable";
import { DEFAULT_TIMETABLE_BASE_URL, normalizeTimetableBaseUrl } from "@homarr/request-handler/timetable-url";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
const baseUrlSchema = z.string().url();
const baseUrlSchema = z.string().url().max(2_048);
const timetableSourceSchema = z.object({
baseUrl: baseUrlSchema,
itemId: z.string().max(100).optional(),
boardId: z.string().max(100).optional(),
});
const resolveTimetableBaseUrlAsync = async (
ctx: { db: Database; session: Session | null },
input: z.infer<typeof timetableSourceSchema>,
allowBoardConfiguration = false,
) => {
const requestedBaseUrl = normalizeTimetableBaseUrl(input.baseUrl);
if (allowBoardConfiguration) {
if (input.boardId) {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
return requestedBaseUrl;
}
if (requestedBaseUrl !== DEFAULT_TIMETABLE_BASE_URL) {
throw new TRPCError({ code: "FORBIDDEN", message: "Board modify access is required for this endpoint" });
}
return DEFAULT_TIMETABLE_BASE_URL;
}
if (!input.itemId) {
if (requestedBaseUrl !== DEFAULT_TIMETABLE_BASE_URL) {
throw new TRPCError({ code: "FORBIDDEN", message: "A saved timetable widget is required for this endpoint" });
}
return DEFAULT_TIMETABLE_BASE_URL;
}
const item = await ctx.db.query.items.findFirst({
where: and(eq(items.id, input.itemId), eq(items.kind, "timetable")),
});
if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "Timetable widget not found" });
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view");
const options = parse<Record<string, unknown>>(item.options);
const savedBaseUrl = normalizeTimetableBaseUrl(
typeof options.baseUrl === "string" ? options.baseUrl : DEFAULT_TIMETABLE_BASE_URL,
);
if (requestedBaseUrl !== savedBaseUrl) {
throw new TRPCError({ code: "FORBIDDEN", message: "Timetable URL does not match the saved widget" });
}
return savedBaseUrl;
};
export const timetableRouter = createTRPCRouter({
getTimetable: publicProcedure
.input(
z.object({
baseUrl: baseUrlSchema,
stationId: z.string(),
timetableSourceSchema.extend({
stationId: z.string().min(1).max(200),
limit: z.number().int().min(1).max(100),
}),
)
.query(async ({ input }) => {
const { data } = await timetableGetTimetableRequestHandler.handler(input).getDataAsync();
.query(async ({ ctx, input }) => {
const baseUrl = await resolveTimetableBaseUrlAsync(ctx, input);
const { itemId: _itemId, boardId: _boardId, ...handlerInput } = input;
const { data } = await timetableGetTimetableRequestHandler.handler({ ...handlerInput, baseUrl }).getDataAsync();
return data;
}),
searchStations: publicProcedure
.input(
z.object({
baseUrl: baseUrlSchema,
query: z.string(),
timetableSourceSchema.extend({
query: z.string().max(200),
}),
)
.query(async ({ input }) => {
const { data } = await timetableSearchStationsRequestHandler.handler(input).getDataAsync();
.query(async ({ ctx, input }) => {
const baseUrl = await resolveTimetableBaseUrlAsync(ctx, input, true);
const { itemId: _itemId, boardId: _boardId, ...handlerInput } = input;
const { data } = await timetableSearchStationsRequestHandler.handler({ ...handlerInput, baseUrl }).getDataAsync();
return data;
}),
});
+29 -12
View File
@@ -9,17 +9,34 @@ export const vpnRouter = createTRPCRouter({
getSummaries: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn")))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data, timestamp } = await vpnSummaryHandler.handler(integration, {}).getDataAsync();
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
summary: data,
};
});
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data, timestamp } = await vpnSummaryHandler.handler(integration, {}).getDataAsync();
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
summary: data as typeof data | null,
error: undefined as string | undefined,
};
},
{
fallback: (integration, error) => ({
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: new Date(0),
},
summary: null,
error: error instanceof Error ? error.message : String(error),
}),
throwOnAllFailures: true,
},
);
}),
});
+7 -3
View File
@@ -11,6 +11,7 @@ interface IntegrationLike {
interface Options<TIntegration extends IntegrationLike, TResult> {
fallback?: (integration: TIntegration, error: unknown) => TResult;
throwOnAllFailures?: boolean;
}
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult>(
@@ -37,15 +38,18 @@ export async function settleIntegrationQueries<TIntegration extends IntegrationL
),
);
errors.push(result.reason);
if (options?.fallback && integration) {
results.push(options.fallback(integration, result.reason));
return;
}
errors.push(result.reason);
});
if (results.length === 0 && errors.length > 0) {
if (
errors.length > 0 &&
(results.length === 0 || (options?.throwOnAllFailures === true && errors.length === integrations.length))
) {
throw errors[0];
}
@@ -47,4 +47,21 @@ describe("settleIntegrationQueries", () => {
}),
).resolves.toEqual(["First", "Second"]);
});
test("can retain partial fallbacks while rejecting total failure", async () => {
const error = new Error("Integration unavailable");
const options = {
fallback: (integration: (typeof integrations)[number]) => integration.name,
throwOnAllFailures: true,
};
await expect(
settleIntegrationQueries(
integrations,
(integration) => (integration.id === "first" ? Promise.reject(error) : Promise.resolve(integration.name)),
options,
),
).resolves.toEqual(["First", "Second"]);
await expect(settleIntegrationQueries(integrations, () => Promise.reject(error), options)).rejects.toBe(error);
});
});