Simplify Custom JSX v2 authoring
This commit is contained in:
177 files changed
+4775
-43426
No files matched your search
@@ -81,7 +81,7 @@ export const customWidgetRouter = createTRPCRouter({
|
||||
encryptedValue: encryptSecret(secret.value),
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
const sourceIds = definition.sources.map((source) => source.id);
|
||||
const sourceIds = Object.keys(definition.sources);
|
||||
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
@@ -100,11 +100,11 @@ export const customWidgetRouter = createTRPCRouter({
|
||||
),
|
||||
);
|
||||
|
||||
for (const source of definition.sources) {
|
||||
const kinds = [...requiredSecretKinds(source.auth.type)];
|
||||
for (const [sourceId, source] of Object.entries(definition.sources)) {
|
||||
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
|
||||
const where = and(
|
||||
eq(schema.customWidgetSecrets.definitionId, id),
|
||||
eq(schema.customWidgetSecrets.sourceId, source.id),
|
||||
eq(schema.customWidgetSecrets.sourceId, sourceId),
|
||||
);
|
||||
await transaction
|
||||
.delete(schema.customWidgetSecrets)
|
||||
@@ -138,9 +138,9 @@ export const customWidgetRouter = createTRPCRouter({
|
||||
.where(and(eq(customWidgetSecrets.definitionId, id), notInArray(customWidgetSecrets.sourceId, sourceIds)))
|
||||
.run();
|
||||
|
||||
for (const source of definition.sources) {
|
||||
const kinds = [...requiredSecretKinds(source.auth.type)];
|
||||
const where = and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, source.id));
|
||||
for (const [sourceId, source] of Object.entries(definition.sources)) {
|
||||
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
|
||||
const where = and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, sourceId));
|
||||
transaction
|
||||
.delete(customWidgetSecrets)
|
||||
.where(kinds.length > 0 ? and(where, notInArray(customWidgetSecrets.kind, kinds)) : where)
|
||||
|
||||
@@ -5,13 +5,22 @@ import { decryptSecret } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { eq, or } from "@homarr/db";
|
||||
import { boards, customWidgetDefinitions } from "@homarr/db/schema";
|
||||
import { getCustomWidgetSecretRequirements } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
getCustomWidgetConfirmation,
|
||||
getCustomWidgetDefaultOptions,
|
||||
getCustomWidgetSecretRequirements,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
|
||||
import { permissionRequiredProcedure, protectedProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
import { parseStoredCustomWidgetDefinition, safeParseStoredCustomWidgetDefinition } from "./stored-definition";
|
||||
import { executeCustomWidgetRequest } from "./request-executor";
|
||||
import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "./request-manifest";
|
||||
import {
|
||||
hashRuntimeParams,
|
||||
renderRequestBody,
|
||||
renderRequestTarget,
|
||||
resolveCustomWidgetRequestValues,
|
||||
} from "./request-manifest";
|
||||
import { acquireCustomWidgetRequestLimit } from "./request-limits";
|
||||
import { getCustomWidgetCacheVersion } from "./cache-version";
|
||||
|
||||
@@ -41,7 +50,7 @@ export const managementQueryProcedures = {
|
||||
sources: [],
|
||||
requestCount: 0,
|
||||
missingSecrets: [],
|
||||
defaultOptions: {},
|
||||
options: {},
|
||||
updatedAt: definition.updatedAt,
|
||||
enabled: definition.enabled,
|
||||
valid: false as const,
|
||||
@@ -56,18 +65,18 @@ export const managementQueryProcedures = {
|
||||
name: widget.name,
|
||||
description: widget.description,
|
||||
iconUrl: widget.iconUrl,
|
||||
sources: widget.sources.map(({ id, name, baseUrl, networkScope, auth }) => ({
|
||||
sources: Object.entries(widget.sources).map(([id, { name, baseUrl, networkScope, auth }]) => ({
|
||||
id,
|
||||
name,
|
||||
origin: new URL(baseUrl).origin,
|
||||
networkScope,
|
||||
authType: auth.type,
|
||||
authType: typeof auth === "string" ? auth : auth.type,
|
||||
})),
|
||||
requestCount: widget.requests.length,
|
||||
requestCount: Object.keys(widget.requests).length,
|
||||
missingSecrets: getCustomWidgetSecretRequirements(widget.sources).filter(
|
||||
(requirement) => !configuredSecrets.has(`${requirement.sourceId}:${requirement.kind}`),
|
||||
),
|
||||
defaultOptions: widget.defaultOptions,
|
||||
options: widget.options,
|
||||
updatedAt: definition.updatedAt,
|
||||
enabled: definition.enabled,
|
||||
valid: true as const,
|
||||
@@ -123,29 +132,29 @@ export const managementQueryProcedures = {
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
iconUrl: definition.iconUrl,
|
||||
optionsSchema: widget.optionsSchema,
|
||||
defaultOptions: widget.defaultOptions,
|
||||
options: widget.options,
|
||||
defaultOptions: getCustomWidgetDefaultOptions(widget.options),
|
||||
template: widget.template,
|
||||
sources: widget.sources.map(({ id, name, networkScope, auth }) => ({
|
||||
sources: Object.entries(widget.sources).map(([id, { name, networkScope, auth }]) => ({
|
||||
id,
|
||||
name,
|
||||
networkScope,
|
||||
authType: auth.type,
|
||||
authType: typeof auth === "string" ? auth : auth.type,
|
||||
})),
|
||||
requestCapabilities: widget.requests.map(
|
||||
({ id, kind, method, trigger, minimumBoardPermission, confirmation, invalidates }) => ({
|
||||
requestCapabilities: Object.entries(widget.requests).map(
|
||||
([id, { kind, method, trigger, permission, confirmation, invalidates }]) => ({
|
||||
id,
|
||||
kind,
|
||||
method,
|
||||
trigger,
|
||||
minimumBoardPermission,
|
||||
confirmation,
|
||||
minimumBoardPermission: permission,
|
||||
confirmation: getCustomWidgetConfirmation({ method, confirmation }),
|
||||
invalidates,
|
||||
}),
|
||||
),
|
||||
optionRequests: widget.requests
|
||||
.filter((request) => request.kind === "query")
|
||||
.map(({ id, parameters, optionsBinding }) => ({ id, parameters, optionsBinding })),
|
||||
optionRequests: Object.entries(widget.requests).flatMap(([id, request]) =>
|
||||
request.kind === "query" ? [{ id }] : [],
|
||||
),
|
||||
updatedAt: definition.updatedAt,
|
||||
};
|
||||
});
|
||||
@@ -168,12 +177,12 @@ export const managementQueryProcedures = {
|
||||
});
|
||||
if (!stored || !stored.enabled) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget not found" });
|
||||
const definition = parseStoredCustomWidgetDefinition(stored);
|
||||
const request = definition.requests.find(
|
||||
(candidate) => candidate.id === input.requestId && candidate.kind === "query",
|
||||
);
|
||||
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Option query not found" });
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), request.minimumBoardPermission);
|
||||
const source = definition.sources.find((candidate) => candidate.id === request.sourceId);
|
||||
const requestDefinition = definition.requests[input.requestId];
|
||||
if (requestDefinition?.kind !== "query")
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Option query not found" });
|
||||
const request = { id: input.requestId, ...requestDefinition };
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), request.permission);
|
||||
const source = definition.sources[request.source];
|
||||
if (!source) throw new TRPCError({ code: "NOT_FOUND", message: "Option query source not found" });
|
||||
const release = await acquireCustomWidgetRequestLimit({
|
||||
category: "query",
|
||||
@@ -182,31 +191,33 @@ export const managementQueryProcedures = {
|
||||
definitionId: input.definitionId,
|
||||
});
|
||||
try {
|
||||
const values = resolveCustomWidgetRequestValues(request, input.params, {});
|
||||
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
|
||||
const response = await executeCustomWidgetRequest({
|
||||
baseUrl: source.baseUrl,
|
||||
targetUrl: renderRequestTarget(source.baseUrl, request, input.params),
|
||||
targetUrl: renderRequestTarget(source.baseUrl, request, values),
|
||||
method: request.method,
|
||||
body: renderRequestBody(request.bodyTemplate, input.params),
|
||||
staticHeaders: request.staticHeaders,
|
||||
body: renderRequestBody(request, values),
|
||||
staticHeaders: request.headers,
|
||||
auth:
|
||||
request.auth === "none" || source.auth.type === "none"
|
||||
request.auth === "none" || authType === "none"
|
||||
? undefined
|
||||
: {
|
||||
type: source.auth.type,
|
||||
type: authType,
|
||||
secrets: stored.secrets
|
||||
.filter((secret) => secret.sourceId === source.id)
|
||||
.filter((secret) => secret.sourceId === request.source)
|
||||
.map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.encryptedValue) })),
|
||||
headerName:
|
||||
source.auth.type === "apiKeyHeader"
|
||||
? source.auth.headerName
|
||||
: source.auth.type === "apiKeyQuery"
|
||||
? source.auth.parameterName
|
||||
typeof source.auth === "object" && source.auth.type === "apiKeyHeader"
|
||||
? source.auth.name
|
||||
: typeof source.auth === "object" && source.auth.type === "apiKeyQuery"
|
||||
? source.auth.name
|
||||
: undefined,
|
||||
},
|
||||
networkScope: source.networkScope,
|
||||
kind: "query",
|
||||
cacheKey: `custom-widget:options:${input.definitionId}:${getCustomWidgetCacheVersion(stored)}:${request.id}:${hashRuntimeParams(input.params)}`,
|
||||
cacheTtlSeconds: request.cacheTtlSeconds ?? 30,
|
||||
cacheKey: `custom-widget:options:${input.definitionId}:${getCustomWidgetCacheVersion(stored)}:${request.id}:${hashRuntimeParams(values)}`,
|
||||
cacheTtlSeconds: request.cacheSeconds ?? 30,
|
||||
});
|
||||
return {
|
||||
ok: response.ok,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
recordPreviewJournal,
|
||||
} from "./preview-procedure-helpers";
|
||||
import { executeCustomWidgetRequest, invalidateCustomWidgetResponseCache } from "./request-executor";
|
||||
import { renderRequestBody, renderRequestTarget } from "./request-manifest";
|
||||
import { renderRequestBody, renderRequestTarget, resolveCustomWidgetRequestValues } from "./request-manifest";
|
||||
import { acquireCustomWidgetRequestLimit } from "./request-limits";
|
||||
import { getPreviewSession } from "./preview-sessions";
|
||||
|
||||
@@ -22,20 +22,21 @@ export const previewActionProcedures = {
|
||||
.input(previewSessionRequestSchema.extend({ confirmed: z.boolean().optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
|
||||
const request = session.requests.find(
|
||||
(candidate) => candidate.id === input.requestId && candidate.kind === "action",
|
||||
);
|
||||
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview action was not found" });
|
||||
const resolved = getPreviewRequestSource(session, request.sourceId);
|
||||
const definition = session.requests[input.requestId];
|
||||
if (definition?.kind !== "action")
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Preview action was not found" });
|
||||
const request = { id: input.requestId, ...definition };
|
||||
const resolved = getPreviewRequestSource(session, request.source);
|
||||
if (!resolved) throw new TRPCError({ code: "NOT_FOUND", message: "Preview source was not found" });
|
||||
const targetUrl = renderRequestTarget(resolved.source.baseUrl, request, input.params);
|
||||
const body = renderRequestBody(request.bodyTemplate, input.params);
|
||||
const params = resolveCustomWidgetRequestValues(request, session.options, input.params);
|
||||
const targetUrl = renderRequestTarget(resolved.source.baseUrl, request, params);
|
||||
const body = renderRequestBody(request, params);
|
||||
if (!session.liveActions) {
|
||||
await recordPreviewJournal(session, {
|
||||
requestId: request.id,
|
||||
kind: "action",
|
||||
method: request.method,
|
||||
pathTemplate: request.pathTemplate,
|
||||
path: request.path,
|
||||
status: null,
|
||||
durationMs: 0,
|
||||
simulated: true,
|
||||
@@ -58,7 +59,7 @@ export const previewActionProcedures = {
|
||||
targetUrl,
|
||||
method: request.method,
|
||||
body,
|
||||
staticHeaders: request.staticHeaders,
|
||||
staticHeaders: request.headers,
|
||||
auth: request.auth === "none" ? undefined : resolved.auth,
|
||||
networkScope: resolved.source.networkScope,
|
||||
kind: "action",
|
||||
@@ -72,7 +73,7 @@ export const previewActionProcedures = {
|
||||
requestId: request.id,
|
||||
kind: "action",
|
||||
method: request.method,
|
||||
pathTemplate: request.pathTemplate,
|
||||
path: request.path,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
simulated: false,
|
||||
|
||||
@@ -5,6 +5,8 @@ import { decryptSecret } from "@homarr/common/server";
|
||||
import {
|
||||
customWidgetDefinitionSchema,
|
||||
customWidgetSecretsInputSchema,
|
||||
getCustomWidgetConfirmation,
|
||||
getCustomWidgetDefaultOptions,
|
||||
validateCustomWidgetOptions,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import { eq } from "@homarr/db";
|
||||
@@ -29,8 +31,8 @@ const previewCreateProcedure = manageProcedure
|
||||
if (input.secrets.length > 0 && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Writing preview secrets requires dedicated permission" });
|
||||
}
|
||||
const options = input.options ?? input.definition.defaultOptions;
|
||||
const optionIssues = validateCustomWidgetOptions(input.definition.optionsSchema, options);
|
||||
const options = input.options ?? getCustomWidgetDefaultOptions(input.definition.options);
|
||||
const optionIssues = validateCustomWidgetOptions(input.definition.options, options);
|
||||
if (optionIssues.length > 0) {
|
||||
const issue = optionIssues[0];
|
||||
throw new TRPCError({
|
||||
@@ -54,11 +56,12 @@ const previewCreateProcedure = manageProcedure
|
||||
}
|
||||
|
||||
const invalid = secrets.find((secret) => {
|
||||
const source = input.definition.sources.find((candidate) => candidate.id === secret.sourceId);
|
||||
const source = input.definition.sources[secret.sourceId];
|
||||
const authType = typeof source?.auth === "string" ? source.auth : source?.auth.type;
|
||||
const kinds =
|
||||
source?.auth.type === "basic"
|
||||
authType === "basic"
|
||||
? ["username", "password"]
|
||||
: source && ["bearer", "apiKeyHeader", "apiKeyQuery"].includes(source.auth.type)
|
||||
: source && authType && ["bearer", "apiKeyHeader", "apiKeyQuery"].includes(authType)
|
||||
? ["apiKey"]
|
||||
: [];
|
||||
return !source || !kinds.includes(secret.kind);
|
||||
@@ -73,7 +76,7 @@ const previewCreateProcedure = manageProcedure
|
||||
requests: input.definition.requests,
|
||||
name: input.definition.name,
|
||||
template: input.definition.template,
|
||||
optionsSchema: input.definition.optionsSchema,
|
||||
optionDefinitions: input.definition.options,
|
||||
options,
|
||||
secrets,
|
||||
definitionId: input.definitionId,
|
||||
@@ -87,7 +90,7 @@ const previewCreateProcedure = manageProcedure
|
||||
).toString(),
|
||||
definition: {
|
||||
template: input.definition.template,
|
||||
defaultOptions: input.definition.defaultOptions,
|
||||
defaultOptions: getCustomWidgetDefaultOptions(input.definition.options),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -101,31 +104,17 @@ export const previewBaseProcedures = {
|
||||
name: session.name,
|
||||
expiresAt: session.expiresAt,
|
||||
template: session.template,
|
||||
optionsSchema: session.optionsSchema,
|
||||
optionDefinitions: session.optionDefinitions,
|
||||
options: session.options,
|
||||
requests: session.requests.map(
|
||||
({
|
||||
id,
|
||||
kind,
|
||||
method,
|
||||
minimumBoardPermission,
|
||||
trigger,
|
||||
parameters,
|
||||
optionsBinding,
|
||||
confirmation,
|
||||
invalidates,
|
||||
}) => ({
|
||||
id,
|
||||
kind,
|
||||
method,
|
||||
minimumBoardPermission,
|
||||
trigger,
|
||||
parameters,
|
||||
optionsBinding,
|
||||
confirmation,
|
||||
invalidates,
|
||||
}),
|
||||
),
|
||||
requests: Object.entries(session.requests).map(([id, request]) => ({
|
||||
id,
|
||||
kind: request.kind,
|
||||
method: request.method,
|
||||
minimumBoardPermission: request.permission,
|
||||
trigger: request.trigger,
|
||||
confirmation: getCustomWidgetConfirmation(request),
|
||||
invalidates: request.invalidates,
|
||||
})),
|
||||
liveActions: session.liveActions,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { resolveCustomWidgetOptionsBinding } from "@homarr/custom-widgets/core";
|
||||
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { appendPreviewJournal } from "./preview-sessions";
|
||||
import { getPreviewSessionSecrets } from "./preview-sessions";
|
||||
import type { CustomWidgetPreviewSession } from "./preview-sessions";
|
||||
import { resolveCustomWidgetRequestValues } from "./request-manifest";
|
||||
|
||||
const logger = createLogger({ module: "custom-widget-preview" });
|
||||
|
||||
@@ -27,19 +27,20 @@ export const recordPreviewJournal = async (...args: Parameters<typeof appendPrev
|
||||
};
|
||||
|
||||
export const getPreviewRequestSource = (session: CustomWidgetPreviewSession, sourceId: string) => {
|
||||
const source = session.sources.find((candidate) => candidate.id === sourceId);
|
||||
const source = session.sources[sourceId];
|
||||
if (!source) return null;
|
||||
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
|
||||
const auth =
|
||||
source.auth.type === "none"
|
||||
authType === "none"
|
||||
? undefined
|
||||
: {
|
||||
type: source.auth.type,
|
||||
secrets: getPreviewSessionSecrets(session, source.id),
|
||||
type: authType,
|
||||
secrets: getPreviewSessionSecrets(session, sourceId),
|
||||
headerName:
|
||||
source.auth.type === "apiKeyHeader"
|
||||
? source.auth.headerName
|
||||
: source.auth.type === "apiKeyQuery"
|
||||
? source.auth.parameterName
|
||||
typeof source.auth === "object" && source.auth.type === "apiKeyHeader"
|
||||
? source.auth.name
|
||||
: typeof source.auth === "object" && source.auth.type === "apiKeyQuery"
|
||||
? source.auth.name
|
||||
: undefined,
|
||||
};
|
||||
return { source, auth };
|
||||
@@ -50,5 +51,5 @@ export function resolvePreviewRequestParams(
|
||||
options: Record<string, unknown>,
|
||||
suppliedParams: Record<string, string | number | boolean>,
|
||||
) {
|
||||
return request.trigger === "load" ? resolveCustomWidgetOptionsBinding(request, options) : suppliedParams;
|
||||
return resolveCustomWidgetRequestValues(request, options, suppliedParams);
|
||||
}
|
||||
@@ -26,15 +26,15 @@ export const previewQueryProcedures = {
|
||||
.input(previewSessionRequestSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
|
||||
const request = session.requests.find(
|
||||
(candidate) => candidate.id === input.requestId && candidate.kind === "query",
|
||||
);
|
||||
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview query was not found" });
|
||||
const resolved = getPreviewRequestSource(session, request.sourceId);
|
||||
const definition = session.requests[input.requestId];
|
||||
if (definition?.kind !== "query")
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Preview query was not found" });
|
||||
const request = { id: input.requestId, ...definition };
|
||||
const resolved = getPreviewRequestSource(session, request.source);
|
||||
if (!resolved) throw new TRPCError({ code: "NOT_FOUND", message: "Preview source was not found" });
|
||||
const params = resolvePreviewRequestParams(request, session.options, input.params);
|
||||
const targetUrl = renderRequestTarget(resolved.source.baseUrl, request, params);
|
||||
const body = renderRequestBody(request.bodyTemplate, params);
|
||||
const body = renderRequestBody(request, params);
|
||||
const release = await acquireCustomWidgetRequestLimit({
|
||||
category: "query",
|
||||
userId: ctx.session.user.id,
|
||||
@@ -48,18 +48,18 @@ export const previewQueryProcedures = {
|
||||
targetUrl,
|
||||
method: request.method,
|
||||
body,
|
||||
staticHeaders: request.staticHeaders,
|
||||
staticHeaders: request.headers,
|
||||
auth: request.auth === "none" ? undefined : resolved.auth,
|
||||
networkScope: resolved.source.networkScope,
|
||||
kind: "query",
|
||||
cacheKey: `custom-jsx:preview:${session.id}:${request.id}:${hashRuntimeParams(params)}`,
|
||||
cacheTtlSeconds: request.cacheTtlSeconds,
|
||||
cacheTtlSeconds: request.cacheSeconds,
|
||||
});
|
||||
await recordPreviewJournal(session, {
|
||||
requestId: request.id,
|
||||
kind: "query",
|
||||
method: request.method,
|
||||
pathTemplate: request.pathTemplate,
|
||||
path: request.path,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
simulated: false,
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
hashRuntimeParams,
|
||||
renderRequestBody as renderDomainBody,
|
||||
renderRequestTarget as renderDomainTarget,
|
||||
validateRuntimeParams as validateDomainParams,
|
||||
resolveCustomWidgetRequestValues as resolveDomainValues,
|
||||
} from "@homarr/custom-widgets/server";
|
||||
import type { CustomJsxRuntimeParams } from "@homarr/custom-widgets/server";
|
||||
|
||||
@@ -12,26 +12,32 @@ import { toTrpcError } from "./domain-error";
|
||||
export type { CustomJsxRuntimeParams } from "@homarr/custom-widgets/server";
|
||||
export { hashRuntimeParams };
|
||||
|
||||
export function validateRuntimeParams(request: CustomJsxRequest, params: CustomJsxRuntimeParams): void {
|
||||
type CustomJsxResolvedValues = ReturnType<typeof resolveDomainValues>;
|
||||
|
||||
export function resolveCustomWidgetRequestValues(
|
||||
request: CustomJsxRequest,
|
||||
options: Record<string, unknown>,
|
||||
params: CustomJsxRuntimeParams = {},
|
||||
) {
|
||||
try {
|
||||
validateDomainParams(request, params);
|
||||
return resolveDomainValues(request, options, params);
|
||||
} catch (error) {
|
||||
toTrpcError(error);
|
||||
return toTrpcError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderRequestBody(request: CustomJsxRequest["bodyTemplate"], params: CustomJsxRuntimeParams) {
|
||||
export function renderRequestBody(request: CustomJsxRequest, params: CustomJsxResolvedValues) {
|
||||
try {
|
||||
return renderDomainBody(request, params);
|
||||
} catch (error) {
|
||||
toTrpcError(error);
|
||||
return toTrpcError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderRequestTarget(baseUrl: string, request: CustomJsxRequest, params: CustomJsxRuntimeParams) {
|
||||
export function renderRequestTarget(baseUrl: string, request: CustomJsxRequest, params: CustomJsxResolvedValues) {
|
||||
try {
|
||||
return renderDomainTarget(baseUrl, request, params);
|
||||
} catch (error) {
|
||||
toTrpcError(error);
|
||||
return toTrpcError(error);
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,13 @@ export function requiredSecretKinds(authType: string) {
|
||||
}
|
||||
|
||||
export function assertSecretSources(
|
||||
sources: readonly { id: string; auth: { type: string } }[],
|
||||
sources: Record<string, { auth: string | { type: string } }>,
|
||||
secrets: readonly { sourceId: string; kind: "apiKey" | "username" | "password" }[],
|
||||
) {
|
||||
const invalid = secrets.find((secret) => {
|
||||
const source = sources.find((candidate) => candidate.id === secret.sourceId);
|
||||
return !source || !new Set<string>(requiredSecretKinds(source.auth.type)).has(secret.kind);
|
||||
const source = sources[secret.sourceId];
|
||||
const authType = typeof source?.auth === "string" ? source.auth : source?.auth.type;
|
||||
return !source || !authType || !new Set<string>(requiredSecretKinds(authType)).has(secret.kind);
|
||||
});
|
||||
if (invalid) {
|
||||
throw new TRPCError({
|
||||
|
||||
@@ -80,7 +80,7 @@ export const secretProcedures = {
|
||||
}
|
||||
|
||||
let widgetName: string;
|
||||
let source: { id: string; name: string; auth: { type: string } } | undefined;
|
||||
let source: { id: string; name: string; auth: string | { type: string } } | undefined;
|
||||
let target: { type: "definition"; id: string } | { type: "preview"; id: string };
|
||||
if (input.definitionId) {
|
||||
const stored = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
@@ -89,16 +89,22 @@ export const secretProcedures = {
|
||||
if (!stored) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
const definition = parseStoredCustomWidgetDefinition(stored);
|
||||
widgetName = definition.name;
|
||||
source = definition.sources.find((candidate) => candidate.id === input.sourceId);
|
||||
const candidate = definition.sources[input.sourceId ?? ""];
|
||||
source = candidate
|
||||
? { id: input.sourceId ?? "", name: candidate.name ?? input.sourceId ?? "", auth: candidate.auth }
|
||||
: undefined;
|
||||
target = { type: "definition", id: input.definitionId };
|
||||
} else {
|
||||
const preview = await getPreviewSession(input.previewSessionId ?? "", ctx.session.user.id);
|
||||
widgetName = "Custom widget preview";
|
||||
source = preview.sources.find((candidate) => candidate.id === input.sourceId);
|
||||
const candidate = preview.sources[input.sourceId ?? ""];
|
||||
source = candidate
|
||||
? { id: input.sourceId ?? "", name: candidate.name ?? input.sourceId ?? "", auth: candidate.auth }
|
||||
: undefined;
|
||||
target = { type: "preview", id: preview.id };
|
||||
}
|
||||
if (!source) throw new TRPCError({ code: "NOT_FOUND", message: "Widget source not found" });
|
||||
const kinds = [...requiredSecretKinds(source.auth.type)];
|
||||
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
|
||||
if (kinds.length === 0) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "This source does not require a secret" });
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { CustomWidgetDefinition } from "@homarr/db/schema";
|
||||
|
||||
type StoredCustomWidgetDefinition = Pick<
|
||||
CustomWidgetDefinition,
|
||||
"name" | "description" | "iconUrl" | "sources" | "requests" | "optionsSchema" | "defaultOptions" | "template"
|
||||
"name" | "description" | "iconUrl" | "sources" | "requests" | "options" | "template"
|
||||
>;
|
||||
|
||||
export interface StoredCustomWidgetIssue {
|
||||
@@ -25,8 +25,7 @@ export function serializeCustomWidgetDefinition(definition: HomarrCustomWidgetV2
|
||||
iconUrl: definition.iconUrl ?? null,
|
||||
sources: stringifySuperJson(definition.sources),
|
||||
requests: stringifySuperJson(definition.requests),
|
||||
optionsSchema: stringifySuperJson(definition.optionsSchema),
|
||||
defaultOptions: stringifySuperJson(definition.defaultOptions),
|
||||
options: stringifySuperJson(definition.options),
|
||||
template: definition.template,
|
||||
};
|
||||
}
|
||||
@@ -39,8 +38,7 @@ export function parseStoredCustomWidgetDefinition(definition: StoredCustomWidget
|
||||
iconUrl: definition.iconUrl ?? undefined,
|
||||
sources: parseSuperJson(definition.sources),
|
||||
requests: parseSuperJson(definition.requests),
|
||||
optionsSchema: parseSuperJson(definition.optionsSchema),
|
||||
defaultOptions: parseSuperJson(definition.defaultOptions),
|
||||
options: parseSuperJson(definition.options),
|
||||
template: definition.template,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,27 +5,28 @@ import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
|
||||
import { resolvePreviewRequestParams } from "../../custom-widget/preview-procedure-helpers";
|
||||
|
||||
const loadRequest: CustomJsxRequest = {
|
||||
id: "containers",
|
||||
sourceId: "default",
|
||||
source: "default",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: "/endpoints/{endpointId}/containers",
|
||||
parameters: { endpointId: "number" },
|
||||
optionsBinding: { endpointId: { $option: "environmentId" } },
|
||||
path: "/endpoints/{option:environmentId}/containers",
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "view",
|
||||
permission: "view",
|
||||
trigger: "load",
|
||||
};
|
||||
|
||||
describe("preview request option bindings", () => {
|
||||
it("resolves load request parameters from the selected preview options", () => {
|
||||
expect(resolvePreviewRequestParams(loadRequest, { environmentId: 7 }, { endpointId: 1 })).toEqual({
|
||||
endpointId: 7,
|
||||
expect(resolvePreviewRequestParams(loadRequest, { environmentId: 7 }, {})).toEqual({
|
||||
"option:environmentId": 7,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves invoking component parameters for manual requests", () => {
|
||||
const manualRequest = { ...loadRequest, trigger: "manual" as const, optionsBinding: undefined };
|
||||
const manualRequest = {
|
||||
...loadRequest,
|
||||
trigger: "manual" as const,
|
||||
path: "/endpoints/{param:endpointId}/containers",
|
||||
};
|
||||
expect(resolvePreviewRequestParams(manualRequest, { environmentId: 7 }, { endpointId: 12 })).toEqual({
|
||||
endpointId: 12,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { parse as parseSuperJson, stringify as stringifySuperJson } from "superjson";
|
||||
import { stringify as stringifySuperJson } from "superjson";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { BUNDLED_CUSTOM_WIDGETS, CUSTOM_WIDGET_STARTER } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
BUNDLED_CUSTOM_WIDGETS,
|
||||
CUSTOM_WIDGET_STARTER,
|
||||
customWidgetDefinitionSchema,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
|
||||
import {
|
||||
safeParseStoredCustomWidgetDefinition,
|
||||
@@ -10,36 +14,31 @@ import {
|
||||
|
||||
describe("stored custom widget definitions", () => {
|
||||
test("parses a valid stored definition", () => {
|
||||
const result = safeParseStoredCustomWidgetDefinition(serializeCustomWidgetDefinition(CUSTOM_WIDGET_STARTER));
|
||||
const result = safeParseStoredCustomWidgetDefinition(
|
||||
serializeCustomWidgetDefinition(customWidgetDefinitionSchema.parse(CUSTOM_WIDGET_STARTER)),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ success: true, widget: { $schema: "homarr-custom-widget-v2" } });
|
||||
});
|
||||
|
||||
test("returns all useful issues for a stale load request instead of throwing", () => {
|
||||
test("returns useful issues for an invalid stored request instead of throwing", () => {
|
||||
const currency = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-currency-exchange");
|
||||
if (!currency) throw new Error("Currency Exchange bundled widget is missing");
|
||||
|
||||
const stored = serializeCustomWidgetDefinition(currency.widget);
|
||||
const requests = parseSuperJson(stored.requests) as Array<Record<string, unknown>>;
|
||||
delete requests[0]?.optionsBinding;
|
||||
|
||||
const stored = serializeCustomWidgetDefinition(customWidgetDefinitionSchema.parse(currency.widget));
|
||||
const result = safeParseStoredCustomWidgetDefinition({
|
||||
...stored,
|
||||
requests: stringifySuperJson(requests),
|
||||
requests: stringifySuperJson({ rates: { path: "/latest/{option:missing}" } }),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({ path: "requests.0.optionsBinding.from" }),
|
||||
expect.objectContaining({ path: "requests.0.optionsBinding.to" }),
|
||||
expect.objectContaining({ path: "requests.0.optionsBinding.amount" }),
|
||||
]),
|
||||
issues: expect.arrayContaining([expect.objectContaining({ path: "requests.rates" })]),
|
||||
});
|
||||
});
|
||||
|
||||
test("contains malformed serialized data", () => {
|
||||
const stored = serializeCustomWidgetDefinition(CUSTOM_WIDGET_STARTER);
|
||||
const stored = serializeCustomWidgetDefinition(customWidgetDefinitionSchema.parse(CUSTOM_WIDGET_STARTER));
|
||||
|
||||
expect(safeParseStoredCustomWidgetDefinition({ ...stored, requests: "not-json" })).toEqual({
|
||||
success: false,
|
||||
|
||||
@@ -27,64 +27,55 @@ import { customApiRouter } from "../../widgets/custom-api";
|
||||
const definition: HomarrCustomWidgetV2 = {
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: "Router test widget",
|
||||
sources: [
|
||||
{
|
||||
id: "default",
|
||||
sources: {
|
||||
default: {
|
||||
name: "API",
|
||||
baseUrl: "https://example.com",
|
||||
networkScope: "public",
|
||||
auth: { type: "none" },
|
||||
auth: "none",
|
||||
},
|
||||
],
|
||||
requests: [
|
||||
{
|
||||
id: "load-status",
|
||||
sourceId: "default",
|
||||
},
|
||||
requests: {
|
||||
"load-status": {
|
||||
source: "default",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: "/status",
|
||||
parameters: {},
|
||||
path: "/status",
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "view",
|
||||
permission: "view",
|
||||
trigger: "load",
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
sourceId: "default",
|
||||
status: {
|
||||
source: "default",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: "/status/{name}",
|
||||
parameters: { name: "string" },
|
||||
path: "/status/{param:name}",
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "view",
|
||||
permission: "view",
|
||||
trigger: "manual",
|
||||
},
|
||||
{
|
||||
id: "toggle",
|
||||
sourceId: "default",
|
||||
toggle: {
|
||||
source: "default",
|
||||
kind: "action",
|
||||
method: "POST",
|
||||
pathTemplate: "/toggle",
|
||||
parameters: { enabled: "boolean" },
|
||||
bodyTemplate: { enabled: { $param: "enabled" } },
|
||||
path: "/toggle",
|
||||
body: { enabled: { $param: "enabled" } },
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "modify",
|
||||
permission: "modify",
|
||||
trigger: "manual",
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
sourceId: "default",
|
||||
delete: {
|
||||
source: "default",
|
||||
kind: "action",
|
||||
method: "DELETE",
|
||||
pathTemplate: "/resource",
|
||||
parameters: {},
|
||||
path: "/resource",
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "full",
|
||||
permission: "full",
|
||||
trigger: "manual",
|
||||
confirmation: { title: "Delete resource", message: "Delete this resource?", destructive: true },
|
||||
},
|
||||
],
|
||||
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
defaultOptions: {},
|
||||
},
|
||||
options: {},
|
||||
template: "<Text>{data['load-status']?.value}</Text>",
|
||||
};
|
||||
|
||||
@@ -149,6 +140,17 @@ describe("Custom JSX v2 board router", () => {
|
||||
.where(eq(customWidgetDefinitions.id, enabledSetup.definitionId));
|
||||
const publicCaller = customApiRouter.createCaller({ db: enabledSetup.db, deviceType: undefined, session: null });
|
||||
await expect(publicCaller.getData({ itemId: enabledSetup.itemId })).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
await expect(
|
||||
publicCaller.queryRequest({ itemId: enabledSetup.itemId, requestId: "status", params: { name: "homarr" } }),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
const ownerCaller = customApiRouter.createCaller({
|
||||
db: enabledSetup.db,
|
||||
deviceType: undefined,
|
||||
session: createSession(enabledSetup.ownerId),
|
||||
});
|
||||
await expect(
|
||||
ownerCaller.executeAction({ itemId: enabledSetup.itemId, requestId: "toggle", params: { enabled: true } }),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -159,7 +161,7 @@ describe("Custom JSX v2 board router", () => {
|
||||
await expect(caller.queryRequest({ itemId, requestId: "toggle", params: { enabled: true } })).rejects.toMatchObject(
|
||||
{ code: "NOT_FOUND" },
|
||||
);
|
||||
await expect(caller.queryRequest({ itemId, requestId: "status", params: { name: 42 } })).rejects.toThrow();
|
||||
await expect(caller.queryRequest({ itemId, requestId: "status", params: {} })).rejects.toThrow();
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -6,14 +6,22 @@ import { decryptSecret } from "@homarr/common/server";
|
||||
import { eq } from "@homarr/db";
|
||||
import { boards, customWidgetDefinitions, items } from "@homarr/db/schema";
|
||||
import type { BoardPermission } from "@homarr/definitions";
|
||||
import { validateCustomWidgetOptions } from "@homarr/custom-widgets/core";
|
||||
import { resolveCustomWidgetOptionsBinding } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
getCustomWidgetConfirmation,
|
||||
getCustomWidgetDefaultOptions,
|
||||
validateCustomWidgetOptions,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import type { CustomJsxRequest, CustomWidgetSource } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
import { executeCustomWidgetRequest, invalidateCustomWidgetResponseCache } from "../custom-widget/request-executor";
|
||||
import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "../custom-widget/request-manifest";
|
||||
import {
|
||||
hashRuntimeParams,
|
||||
renderRequestBody,
|
||||
renderRequestTarget,
|
||||
resolveCustomWidgetRequestValues,
|
||||
} from "../custom-widget/request-manifest";
|
||||
import { acquireCustomWidgetRequestLimit } from "../custom-widget/request-limits";
|
||||
import { getCustomWidgetCacheVersion } from "../custom-widget/cache-version";
|
||||
import { parseStoredCustomWidgetDefinition } from "../custom-widget/stored-definition";
|
||||
@@ -77,8 +85,8 @@ async function resolvePlacedDefinitionAsync(ctx: RouterContext, itemId: string)
|
||||
if (!stored.enabled) throw new TRPCError({ code: "FORBIDDEN", message: "Widget is disabled" });
|
||||
|
||||
const definition = parseStoredCustomWidgetDefinition(stored);
|
||||
const configuration = { ...definition.defaultOptions, ...itemOptions.configuration };
|
||||
const issues = validateCustomWidgetOptions(definition.optionsSchema, configuration);
|
||||
const configuration = { ...getCustomWidgetDefaultOptions(definition.options), ...itemOptions.configuration };
|
||||
const issues = validateCustomWidgetOptions(definition.options, configuration);
|
||||
if (issues.length > 0) {
|
||||
throw new TRPCError({
|
||||
code: "PRECONDITION_FAILED",
|
||||
@@ -89,32 +97,35 @@ async function resolvePlacedDefinitionAsync(ctx: RouterContext, itemId: string)
|
||||
}
|
||||
|
||||
const findRequest = (resolved: ResolvedDefinition, requestId: string, kind: "query" | "action") => {
|
||||
const request = resolved.definition.requests.find(
|
||||
(candidate) => candidate.id === requestId && candidate.kind === kind,
|
||||
);
|
||||
const request = resolved.definition.requests[requestId];
|
||||
if (request?.kind !== kind) throw new TRPCError({ code: "NOT_FOUND", message: "Named request not found" });
|
||||
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Named request not found" });
|
||||
return request;
|
||||
return { id: requestId, ...request };
|
||||
};
|
||||
|
||||
const findSource = (resolved: ResolvedDefinition, sourceId: string) => {
|
||||
const source = resolved.definition.sources.find((candidate) => candidate.id === sourceId);
|
||||
const source = resolved.definition.sources[sourceId];
|
||||
if (!source) throw new TRPCError({ code: "NOT_FOUND", message: "Widget API source not found" });
|
||||
return source;
|
||||
return { id: sourceId, ...source };
|
||||
};
|
||||
|
||||
const getAuth = (resolved: ResolvedDefinition, source: CustomWidgetSource, mode: "inherit" | "none") => {
|
||||
if (mode === "none" || source.auth.type === "none") return undefined;
|
||||
type IdentifiedRequest = CustomJsxRequest & { id: string };
|
||||
type IdentifiedSource = CustomWidgetSource & { id: string };
|
||||
|
||||
const getAuth = (resolved: ResolvedDefinition, source: IdentifiedSource, mode: "inherit" | "none") => {
|
||||
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
|
||||
if (mode === "none" || authType === "none") return undefined;
|
||||
const secrets = resolved.stored.secrets
|
||||
.filter((secret) => secret.sourceId === source.id)
|
||||
.map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.encryptedValue) }));
|
||||
return {
|
||||
type: source.auth.type,
|
||||
type: authType,
|
||||
secrets,
|
||||
headerName:
|
||||
source.auth.type === "apiKeyHeader"
|
||||
? source.auth.headerName
|
||||
: source.auth.type === "apiKeyQuery"
|
||||
? source.auth.parameterName
|
||||
typeof source.auth === "object" && source.auth.type === "apiKeyHeader"
|
||||
? source.auth.name
|
||||
: typeof source.auth === "object" && source.auth.type === "apiKeyQuery"
|
||||
? source.auth.name
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
@@ -122,7 +133,7 @@ const getAuth = (resolved: ResolvedDefinition, source: CustomWidgetSource, mode:
|
||||
const withRequestLimit = async <T>(
|
||||
ctx: RouterContext,
|
||||
resolved: ResolvedDefinition,
|
||||
request: CustomJsxRequest,
|
||||
request: IdentifiedRequest,
|
||||
callback: () => Promise<T>,
|
||||
) => {
|
||||
const release = await acquireCustomWidgetRequestLimit({
|
||||
@@ -138,38 +149,31 @@ const withRequestLimit = async <T>(
|
||||
}
|
||||
};
|
||||
|
||||
const getCacheKey = (
|
||||
resolved: ResolvedDefinition,
|
||||
request: CustomJsxRequest,
|
||||
params: Record<string, string | number | boolean>,
|
||||
) =>
|
||||
const getCacheKey = (resolved: ResolvedDefinition, request: IdentifiedRequest, params: Record<string, unknown>) =>
|
||||
`custom-jsx:${resolved.item.id}:${getCustomWidgetCacheVersion(resolved.stored)}:${request.id}:${hashRuntimeParams(params)}`;
|
||||
|
||||
const executeRequest = async (
|
||||
ctx: RouterContext,
|
||||
resolved: ResolvedDefinition,
|
||||
request: CustomJsxRequest,
|
||||
request: IdentifiedRequest,
|
||||
params: Record<string, string | number | boolean>,
|
||||
) => {
|
||||
await throwIfActionForbiddenAsync(
|
||||
ctx,
|
||||
eq(boards.id, resolved.item.boardId),
|
||||
request.minimumBoardPermission as BoardPermission,
|
||||
);
|
||||
const source = findSource(resolved, request.sourceId);
|
||||
const targetUrl = renderRequestTarget(source.baseUrl, request, params);
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, resolved.item.boardId), request.permission as BoardPermission);
|
||||
const source = findSource(resolved, request.source);
|
||||
const values = resolveCustomWidgetRequestValues(request, resolved.configuration, params);
|
||||
const targetUrl = renderRequestTarget(source.baseUrl, request, values);
|
||||
return withRequestLimit(ctx, resolved, request, () =>
|
||||
executeCustomWidgetRequest({
|
||||
baseUrl: source.baseUrl,
|
||||
targetUrl,
|
||||
method: request.method,
|
||||
body: renderRequestBody(request.bodyTemplate, params),
|
||||
staticHeaders: request.staticHeaders,
|
||||
body: renderRequestBody(request, values),
|
||||
staticHeaders: request.headers,
|
||||
auth: getAuth(resolved, source, request.auth),
|
||||
networkScope: source.networkScope,
|
||||
kind: request.kind,
|
||||
cacheKey: request.kind === "query" ? getCacheKey(resolved, request, params) : undefined,
|
||||
cacheTtlSeconds: request.cacheTtlSeconds,
|
||||
cacheKey: request.kind === "query" ? getCacheKey(resolved, request, values) : undefined,
|
||||
cacheTtlSeconds: request.cacheSeconds,
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -177,16 +181,15 @@ const executeRequest = async (
|
||||
export const customApiRouter = createTRPCRouter({
|
||||
getData: publicProcedure.input(itemInputSchema).query(async ({ ctx, input }) => {
|
||||
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
|
||||
const loadRequests = resolved.definition.requests.filter(
|
||||
(request) => request.kind === "query" && request.trigger === "load",
|
||||
const loadRequests = Object.entries(resolved.definition.requests).filter(
|
||||
([, request]) => request.kind === "query" && request.trigger === "load",
|
||||
);
|
||||
const entries = await Promise.all(
|
||||
loadRequests.map(async (request) => {
|
||||
loadRequests.map(async ([requestId, request]) => {
|
||||
try {
|
||||
const params = resolveCustomWidgetOptionsBinding(request, resolved.configuration);
|
||||
const response = await executeRequest(ctx, resolved, request, params);
|
||||
const response = await executeRequest(ctx, resolved, { id: requestId, ...request }, {});
|
||||
return [
|
||||
request.id,
|
||||
requestId,
|
||||
{
|
||||
data: response.data,
|
||||
status: {
|
||||
@@ -200,7 +203,7 @@ export const customApiRouter = createTRPCRouter({
|
||||
] as const;
|
||||
} catch (error) {
|
||||
return [
|
||||
request.id,
|
||||
requestId,
|
||||
{
|
||||
data: null,
|
||||
status: {
|
||||
@@ -221,13 +224,13 @@ export const customApiRouter = createTRPCRouter({
|
||||
data: Object.fromEntries(entries.map(([id, result]) => [id, result.data])),
|
||||
status: Object.fromEntries(entries.map(([id, result]) => [id, result.status])),
|
||||
options: resolved.configuration,
|
||||
requestCapabilities: resolved.definition.requests.map((request) => ({
|
||||
id: request.id,
|
||||
requestCapabilities: Object.entries(resolved.definition.requests).map(([id, request]) => ({
|
||||
id,
|
||||
kind: request.kind,
|
||||
method: request.method,
|
||||
trigger: request.trigger,
|
||||
minimumBoardPermission: request.minimumBoardPermission,
|
||||
confirmation: request.confirmation,
|
||||
minimumBoardPermission: request.permission,
|
||||
confirmation: getCustomWidgetConfirmation(request),
|
||||
invalidates: request.invalidates ?? [],
|
||||
})),
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user