Add tRPC to search app (#940)

* Add tRPC to search app

* Implemented configuration endpoint in trpc

* replace settings manager to use shared factory

* replace configuration calls to trpc
This commit is contained in:
Lukasz Ostrowski 2023-08-29 22:53:51 +02:00 committed by GitHub
parent 2a1385bab1
commit 0f84985c98
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 424 additions and 302 deletions

View file

@ -0,0 +1,7 @@
---
"saleor-app-search": patch
---
Refactor: Introduced tRPC and re-implemented /api/configuration endpoint to the tRPC controller.
_This does not affect the end-user functionality_

View file

@ -20,6 +20,10 @@
"@saleor/react-hook-form-macaw": "workspace:*", "@saleor/react-hook-form-macaw": "workspace:*",
"@sentry/nextjs": "7.55.2", "@sentry/nextjs": "7.55.2",
"@tanstack/react-query": "4.29.19", "@tanstack/react-query": "4.29.19",
"@trpc/client": "10.34.0",
"@trpc/next": "10.34.0",
"@trpc/react-query": "10.34.0",
"@trpc/server": "10.34.0",
"@types/debug": "^4.1.7", "@types/debug": "^4.1.7",
"@urql/exchange-auth": "^2.1.4", "@urql/exchange-auth": "^2.1.4",
"algoliasearch": "4.19.1", "algoliasearch": "4.19.1",

View file

@ -1,15 +1,15 @@
import { useAuthenticatedFetch } from "@saleor/app-sdk/app-bridge"; import { useAuthenticatedFetch } from "@saleor/app-sdk/app-bridge";
import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDashboardNotification } from "@saleor/apps-shared";
import { fetchConfiguration } from "../lib/configuration";
import { Box, Button, Divider, Text } from "@saleor/macaw-ui/next"; import { Box, Button, Divider, Text } from "@saleor/macaw-ui/next";
import { Input } from "@saleor/react-hook-form-macaw"; import { Input } from "@saleor/react-hook-form-macaw";
import { useDashboardNotification } from "@saleor/apps-shared"; import { useQueryClient } from "@tanstack/react-query";
import { AlgoliaSearchProvider } from "../lib/algolia/algoliaSearchProvider";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form";
import { AppConfigurationFields, AppConfigurationSchema } from "../domain/configuration"; import { AppConfigurationFields, AppConfigurationSchema } from "../domain/configuration";
import { AlgoliaSearchProvider } from "../lib/algolia/algoliaSearchProvider";
import { trpcClient } from "../modules/trpc/trpc-client";
export const AlgoliaConfigurationForm = () => { export const AlgoliaConfigurationForm = () => {
const { notifyError, notifySuccess } = useDashboardNotification(); const { notifyError, notifySuccess } = useDashboardNotification();
@ -24,50 +24,32 @@ export const AlgoliaConfigurationForm = () => {
}); });
const reactQueryClient = useQueryClient(); const reactQueryClient = useQueryClient();
/**
* TODO Extract to hook
*/
const { isLoading: isQueryLoading } = useQuery({
queryKey: ["configuration"],
onSuccess(data) {
setValue("secretKey", data?.secretKey || "");
setValue("appId", data?.appId || "");
setValue("indexNamePrefix", data?.indexNamePrefix || "");
},
queryFn: async () => fetchConfiguration(fetch),
});
const { mutate, isLoading: isMutationLoading } = useMutation( const { isLoading: isQueryLoading, refetch: refetchConfig } =
async (conf: AppConfigurationFields) => { trpcClient.configuration.getConfig.useQuery(undefined, {
const resp = await fetch("/api/configuration", { onSuccess(data) {
method: "POST", setValue("secretKey", data?.secretKey || "");
body: JSON.stringify(conf), setValue("appId", data?.appId || "");
}); setValue("indexNamePrefix", data?.indexNamePrefix || "");
},
});
if (resp.status >= 200 && resp.status < 300) { const { mutate: setConfig, isLoading: isMutationLoading } =
const data = (await resp.json()) as { data?: AppConfigurationFields }; trpcClient.configuration.setConfig.useMutation({
return data.data;
}
throw new Error(`Server responded with status code ${resp.status}`);
},
{
onSuccess: async () => { onSuccess: async () => {
await Promise.all([ await Promise.all([
reactQueryClient.refetchQueries({ refetchConfig(),
queryKey: ["configuration"], // todo migrate to trpc
}),
reactQueryClient.refetchQueries({ reactQueryClient.refetchQueries({
queryKey: ["webhooks-status"], queryKey: ["webhooks-status"],
}), }),
]); ]);
notifySuccess("Configuration saved!"); notifySuccess("Configuration saved!");
}, },
onError: async (data: Error) => { onError: async (error) => {
notifyError("Could not save the configuration", data.message); notifyError("Could not save the configuration", error.message);
}, },
} });
);
const onFormSubmit = handleSubmit(async (conf) => { const onFormSubmit = handleSubmit(async (conf) => {
const client = new AlgoliaSearchProvider({ const client = new AlgoliaSearchProvider({
@ -80,7 +62,7 @@ export const AlgoliaConfigurationForm = () => {
await client.ping(); await client.ping();
setCredentialsValidationError(false); setCredentialsValidationError(false);
mutate(conf); setConfig(conf);
} catch (e) { } catch (e) {
trigger(); trigger();
setCredentialsValidationError(true); setCredentialsValidationError(true);

View file

@ -1,8 +1,8 @@
import { Box, Button, Text } from "@saleor/macaw-ui/next"; import { Box, Button, Text } from "@saleor/macaw-ui/next";
import React, { useCallback, useEffect, useMemo, useState } from "react"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { AlgoliaSearchProvider } from "../lib/algolia/algoliaSearchProvider"; import { AlgoliaSearchProvider } from "../lib/algolia/algoliaSearchProvider";
import { useConfiguration } from "../lib/configuration";
import { Products, useQueryAllProducts } from "./useQueryAllProducts"; import { Products, useQueryAllProducts } from "./useQueryAllProducts";
import { trpcClient } from "../modules/trpc/trpc-client";
const BATCH_SIZE = 100; const BATCH_SIZE = 100;
@ -14,21 +14,21 @@ export const ImportProductsToAlgolia = () => {
const products = useQueryAllProducts(!started); const products = useQueryAllProducts(!started);
const algoliaConfiguration = useConfiguration(); const { data: algoliaConfiguration } = trpcClient.configuration.getConfig.useQuery();
const searchProvider = useMemo(() => { const searchProvider = useMemo(() => {
if (!algoliaConfiguration.data?.appId || !algoliaConfiguration.data.secretKey) { if (!algoliaConfiguration?.appId || !algoliaConfiguration.secretKey) {
return null; return null;
} }
return new AlgoliaSearchProvider({ return new AlgoliaSearchProvider({
appId: algoliaConfiguration.data.appId, appId: algoliaConfiguration.appId,
apiKey: algoliaConfiguration.data.secretKey, apiKey: algoliaConfiguration.secretKey,
indexNamePrefix: algoliaConfiguration.data.indexNamePrefix, indexNamePrefix: algoliaConfiguration.indexNamePrefix,
}); });
}, [ }, [
algoliaConfiguration?.data?.appId, algoliaConfiguration?.appId,
algoliaConfiguration?.data?.indexNamePrefix, algoliaConfiguration?.indexNamePrefix,
algoliaConfiguration?.data?.secretKey, algoliaConfiguration?.secretKey,
]); ]);
const importProducts = useCallback(() => { const importProducts = useCallback(() => {

View file

@ -1,14 +1,14 @@
import { Box, Button, Text } from "@saleor/macaw-ui/next"; import { Box, Button, Text } from "@saleor/macaw-ui/next";
import React from "react"; import React from "react";
import { useConfiguration } from "../lib/configuration";
import { TextLink } from "@saleor/apps-ui"; import { TextLink } from "@saleor/apps-ui";
import { useIndicesSetupMutation } from "../lib/useIndicesSetup"; import { useIndicesSetupMutation } from "../lib/useIndicesSetup";
import { trpcClient } from "../modules/trpc/trpc-client";
export const IndicesSettings = () => { export const IndicesSettings = () => {
const algoliaConfiguration = useConfiguration(); const { data: algoliaConfiguration } = trpcClient.configuration.getConfig.useQuery();
const updateWebhooksMutation = useIndicesSetupMutation(); const updateWebhooksMutation = useIndicesSetupMutation();
const isConfigured = algoliaConfiguration.data?.appId && algoliaConfiguration.data?.secretKey; const isConfigured = algoliaConfiguration?.appId && algoliaConfiguration?.secretKey;
return ( return (
<Box> <Box>

View file

@ -15,7 +15,7 @@ export const getAlgoliaConfiguration = async ({ authData }: GetAlgoliaConfigurat
token: authData.token, token: authData.token,
}); });
const settings = createSettingsManager(client); const settings = createSettingsManager(client, authData.appId);
try { try {
const secretKey = await settings.get("secretKey", authData.domain); const secretKey = await settings.get("secretKey", authData.domain);

View file

@ -1,23 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { useAuthenticatedFetch } from "@saleor/app-sdk/app-bridge";
import { AppConfigurationFields } from "../domain/configuration";
export const fetchConfiguration = async (fetch: typeof window.fetch) => {
try {
const res = await fetch("/api/configuration");
const data = (await res.json()) as { data?: AppConfigurationFields };
return data.data;
} catch (e) {
throw e;
}
};
export const useConfiguration = () => {
const fetch = useAuthenticatedFetch();
return useQuery({
queryKey: ["configuration"],
queryFn: () => fetchConfiguration(fetch),
});
};

View file

@ -1,91 +1,12 @@
import { import { SettingsManager } from "@saleor/app-sdk/settings-manager";
EncryptedMetadataManager, import { EncryptedMetadataManagerFactory } from "@saleor/apps-shared";
MetadataEntry,
SettingsManager,
} from "@saleor/app-sdk/settings-manager";
import {
FetchAppDetailsDocument,
FetchAppDetailsQuery,
UpdateAppMetadataDocument,
} from "../../generated/graphql";
import { settingsManagerSecretKey } from "../../saleor-app";
import { Client } from "urql"; import { Client } from "urql";
/* export const createSettingsManager = (
* Function is using urql graphql client to fetch all available metadata. client: Pick<Client, "query" | "mutation">,
* Before returning query result, we are transforming response to list of objects with key and value fields appId: string,
* which can be used by the manager. ): SettingsManager => {
* Result of this query is cached by the manager. const metadataManagerFactory = new EncryptedMetadataManagerFactory(process.env.SECRET_KEY!);
*/
export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]> {
const { error, data } = await client
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
.toPromise();
if (error) { return metadataManagerFactory.create(client, appId);
console.debug("Error during fetching the metadata: ", error);
return [];
}
return data?.app?.privateMetadata.map((md) => ({ key: md.key, value: md.value })) || [];
}
/*
* Mutate function takes urql client and metadata entries, and construct mutation to the API.
* Before data are send, additional query for required App ID is made.
* The manager will use updated entries returned by this mutation to update it's cache.
*/
export async function mutateMetadata(client: Client, metadata: MetadataEntry[]) {
// to update the metadata, ID is required
const { error: idQueryError, data: idQueryData } = await client
.query(FetchAppDetailsDocument, {})
.toPromise();
if (idQueryError) {
console.debug("Could not fetch the app id: ", idQueryError);
throw new Error(
"Could not fetch the app id. Please check if auth data for the client are valid."
);
}
const appId = idQueryData?.app?.id;
if (!appId) {
console.debug("Missing app id");
throw new Error("Could not fetch the app ID");
}
const { error: mutationError, data: mutationData } = await client
.mutation(UpdateAppMetadataDocument, {
id: appId,
input: metadata,
})
.toPromise();
if (mutationError) {
console.debug("Mutation error: ", mutationError);
throw new Error(`Mutation error: ${mutationError.message}`);
}
return (
mutationData?.updatePrivateMetadata?.item?.privateMetadata.map((md) => ({
key: md.key,
value: md.value,
})) || []
);
}
export const createSettingsManager = (client: Client): SettingsManager => {
/*
* EncryptedMetadataManager gives you interface to manipulate metadata and cache values in memory.
* We recommend it for production, because all values are encrypted.
* If your use case require plain text values, you can use MetadataManager.
*/
return new EncryptedMetadataManager({
// Secret key should be randomly created for production and set as environment variable
encryptionKey: settingsManagerSecretKey,
fetchMetadata: () => fetchAllMetadata(client),
mutateMetadata: (metadata) => mutateMetadata(client, metadata),
});
}; };

View file

@ -0,0 +1,83 @@
import { createLogger } from "@saleor/apps-shared";
import { TRPCError } from "@trpc/server";
import { ChannelsDocument } from "../../../generated/graphql";
import { WebhookActivityTogglerService } from "../../domain/WebhookActivityToggler.service";
import { AppConfigurationFields, AppConfigurationSchema } from "../../domain/configuration";
import { AlgoliaSearchProvider } from "../../lib/algolia/algoliaSearchProvider";
import { createSettingsManager } from "../../lib/metadata";
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
import { router } from "../trpc/trpc-server";
const logger = createLogger({ name: "configuration.router" });
export const configurationRouter = router({
getConfig: protectedClientProcedure.query(async ({ ctx }) => {
const settingsManager = createSettingsManager(ctx.apiClient, ctx.appId);
/**
* Backwards compatbitility
*/
const domain = new URL(ctx.saleorApiUrl).host;
/**
* TODO - refactor to single config in one key
*/
const data: AppConfigurationFields = {
secretKey: (await settingsManager.get("secretKey", domain)) || "",
appId: (await settingsManager.get("appId", domain)) || "",
indexNamePrefix: (await settingsManager.get("indexNamePrefix", domain)) || "",
};
logger.debug("Will return config");
return data;
}),
setConfig: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(AppConfigurationSchema)
.mutation(async ({ input, ctx }) => {
const { data: channelsData } = await ctx.apiClient.query(ChannelsDocument, {}).toPromise();
const channels = channelsData?.channels || [];
const algoliaClient = new AlgoliaSearchProvider({
appId: ctx.appId,
apiKey: input.secretKey,
indexNamePrefix: input.indexNamePrefix,
channels,
});
const settingsManager = createSettingsManager(ctx.apiClient, ctx.appId);
/**
* Backwards compatbitility
*/
const domain = new URL(ctx.saleorApiUrl).host;
try {
logger.debug("Will ping Algolia");
await algoliaClient.ping();
logger.debug("Algolia connection is ok. Will save settings");
await settingsManager.set([
{ key: "secretKey", value: input.secretKey || "", domain },
{ key: "appId", value: input.appId || "", domain },
{ key: "indexNamePrefix", value: input.indexNamePrefix || "", domain },
]);
logger.debug("Settings set successfully");
const webhooksToggler = new WebhookActivityTogglerService(ctx.appId, ctx.apiClient);
await webhooksToggler.enableOwnWebhooks();
logger.debug("Webhooks enabled");
} catch (e) {
throw new TRPCError({
code: "BAD_REQUEST",
});
}
return null;
}),
});

View file

@ -0,0 +1,117 @@
import { verifyJWT } from "@saleor/app-sdk/verify-jwt";
import { middleware, procedure } from "./trpc-server";
import { TRPCError } from "@trpc/server";
import { ProtectedHandlerError } from "@saleor/app-sdk/handlers/next";
import { createGraphQLClient, logger } from "@saleor/apps-shared";
import { saleorApp } from "../../../saleor-app";
const attachAppToken = middleware(async ({ ctx, next }) => {
logger.debug("attachAppToken middleware");
if (!ctx.saleorApiUrl) {
logger.debug("ctx.saleorApiUrl not found, throwing");
throw new TRPCError({
code: "BAD_REQUEST",
message: "Missing saleorApiUrl in request",
});
}
const authData = await saleorApp.apl.get(ctx.saleorApiUrl);
if (!authData) {
logger.debug("authData not found, throwing 401");
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Missing auth data",
});
}
return next({
ctx: {
appToken: authData.token,
saleorApiUrl: authData.saleorApiUrl,
appId: authData.appId,
},
});
});
const validateClientToken = middleware(async ({ ctx, next, meta }) => {
logger.debug(
{
permissions: meta?.requiredClientPermissions,
},
"Calling validateClientToken middleware with permissions required",
);
if (!ctx.token) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Missing token in request. This middleware can be used only in frontend",
});
}
if (!ctx.appId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Missing appId in request. This middleware can be used after auth is attached",
});
}
if (!ctx.saleorApiUrl) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
"Missing saleorApiUrl in request. This middleware can be used after auth is attached",
});
}
if (!ctx.ssr) {
try {
logger.debug("trying to verify JWT token from frontend");
logger.debug({ token: ctx.token ? `${ctx.token[0]}...` : undefined });
await verifyJWT({
appId: ctx.appId,
token: ctx.token,
saleorApiUrl: ctx.saleorApiUrl,
requiredPermissions: meta?.requiredClientPermissions ?? [],
});
} catch (e) {
logger.debug("JWT verification failed, throwing");
throw new ProtectedHandlerError("JWT verification failed: ", "JWT_VERIFICATION_FAILED");
}
}
return next({
ctx: {
...ctx,
saleorApiUrl: ctx.saleorApiUrl,
},
});
});
/**
* Construct common graphQL client and attach it to the context
*
* Can be used only if called from the frontend (react-query),
* otherwise jwks validation will fail (if createCaller used)
*
* TODO Rethink middleware composition to enable safe server-side router calls
*/
export const protectedClientProcedure = procedure
.use(attachAppToken)
.use(validateClientToken)
.use(async ({ ctx, next }) => {
const client = createGraphQLClient({ saleorApiUrl: ctx.saleorApiUrl, token: ctx.appToken });
return next({
ctx: {
apiClient: client,
appToken: ctx.appToken,
saleorApiUrl: ctx.saleorApiUrl,
appId: ctx.appId!,
},
});
});

View file

@ -0,0 +1,8 @@
import { configurationRouter } from "../configuration/configuration.router";
import { router } from "./trpc-server";
export const appRouter = router({
configuration: configurationRouter,
});
export type AppRouter = typeof appRouter;

View file

@ -0,0 +1,45 @@
import { httpBatchLink } from "@trpc/client";
import { createTRPCNext } from "@trpc/next";
import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const";
import { AppRouter } from "./trpc-app-router";
import { appBridgeInstance } from "../../pages/_app";
function getBaseUrl() {
if (typeof window !== "undefined") return "";
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`;
}
export const trpcClient = createTRPCNext<AppRouter>({
config({ ctx }) {
return {
links: [
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
headers() {
const { token, saleorApiUrl } = appBridgeInstance?.getState() || {};
if (!token || !saleorApiUrl) {
console.error(
"Can't initialize tRPC client before establishing the App Bridge connection",
);
throw new Error("Token and Saleor API URL unknown");
}
return {
/**
* Attach headers from app to client requests, so tRPC can add them to context
*/
[SALEOR_AUTHORIZATION_BEARER_HEADER]: appBridgeInstance?.getState().token,
[SALEOR_API_URL_HEADER]: appBridgeInstance?.getState().saleorApiUrl,
};
},
}),
],
queryClientConfig: { defaultOptions: { queries: { refetchOnWindowFocus: false } } },
};
},
ssr: false,
});

View file

@ -0,0 +1,30 @@
import * as trpcNext from "@trpc/server/adapters/next";
import { SALEOR_AUTHORIZATION_BEARER_HEADER, SALEOR_API_URL_HEADER } from "@saleor/app-sdk/const";
import { inferAsyncReturnType } from "@trpc/server";
const getBaseUrl = (headers: { [name: string]: string | string[] | undefined }): string => {
const { host, "x-forwarded-proto": xForwardedProto = "http" } = headers;
const xForwardedProtos = Array.isArray(xForwardedProto)
? xForwardedProto.join(",")
: xForwardedProto;
const protocols = xForwardedProtos.split(",");
// prefer https over other protocols
const protocol = protocols.find((el) => el === "https") || protocols[0];
return `${protocol}://${host}`;
};
export const createTrpcContext = async ({ res, req }: trpcNext.CreateNextContextOptions) => {
const baseUrl = getBaseUrl(req.headers);
return {
token: req.headers[SALEOR_AUTHORIZATION_BEARER_HEADER] as string | undefined,
saleorApiUrl: req.headers[SALEOR_API_URL_HEADER] as string | undefined,
appId: undefined as undefined | string,
ssr: undefined as undefined | boolean,
baseUrl,
};
};
export type TrpcContext = inferAsyncReturnType<typeof createTrpcContext>;

View file

@ -0,0 +1,31 @@
import { initTRPC } from "@trpc/server";
import { TrpcContext } from "./trpc-context";
import { Permission } from "@saleor/app-sdk/types";
import { ZodError } from "zod";
interface Meta {
requiredClientPermissions?: Permission[];
updateWebhooks?: boolean;
}
const t = initTRPC
.context<TrpcContext>()
.meta<Meta>()
.create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.code === "BAD_REQUEST" && error.cause instanceof ZodError
? error.cause.flatten()
: null,
},
};
},
});
export const router = t.router;
export const procedure = t.procedure;
export const middleware = t.middleware;

View file

@ -9,12 +9,13 @@ import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next";
import { ThemeSynchronizer } from "../lib/theme-synchronizer"; import { ThemeSynchronizer } from "../lib/theme-synchronizer";
import { Box, ThemeProvider } from "@saleor/macaw-ui/next"; import { Box, ThemeProvider } from "@saleor/macaw-ui/next";
import { NoSSRWrapper } from "@saleor/apps-shared"; import { NoSSRWrapper } from "@saleor/apps-shared";
import { trpcClient } from "../modules/trpc/trpc-client";
/** /**
* Ensure instance is a singleton. * Ensure instance is a singleton.
* TODO: This is React 18 issue, consider hiding this workaround inside app-sdk * TODO: This is React 18 issue, consider hiding this workaround inside app-sdk
*/ */
const appBridgeInstance = typeof window !== "undefined" ? new AppBridge() : undefined; export const appBridgeInstance = typeof window !== "undefined" ? new AppBridge() : undefined;
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@ -44,4 +45,4 @@ function NextApp({ Component, pageProps }: AppProps) {
); );
} }
export default NextApp; export default trpcClient.withTRPC(NextApp);

View file

@ -1,116 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
import { createSettingsManager } from "../../lib/metadata";
import { saleorApp } from "../../../saleor-app";
import { createProtectedHandler, ProtectedHandlerContext } from "@saleor/app-sdk/handlers/next";
import { createLogger } from "../../lib/logger";
import { AppConfigurationFields } from "../../domain/configuration";
import { AlgoliaSearchProvider } from "../../lib/algolia/algoliaSearchProvider";
import { WebhookActivityTogglerService } from "../../domain/WebhookActivityToggler.service";
import { createGraphQLClient } from "@saleor/apps-shared";
import { ChannelsDocument } from "../../../generated/graphql";
const logger = createLogger({
handler: "api/configuration",
});
export interface SettingsApiResponse {
success: boolean;
data?: AppConfigurationFields;
}
const sendResponse = async (
res: NextApiResponse<SettingsApiResponse>,
statusCode: number,
settings: SettingsManager,
domain: string,
) => {
const data = {
secretKey: (await settings.get("secretKey", domain)) || "",
appId: (await settings.get("appId", domain)) || "",
indexNamePrefix: (await settings.get("indexNamePrefix", domain)) || "",
};
logger.debug(data, "Will return following settings");
res.status(statusCode).json({
success: statusCode === 200,
data,
});
};
export const handler = async (
req: NextApiRequest,
res: NextApiResponse,
ctx: ProtectedHandlerContext,
) => {
const {
authData: { token, saleorApiUrl },
} = ctx;
logger.debug({ saleorApiUrl }, "handler called");
const client = createGraphQLClient({
saleorApiUrl,
token,
});
const settings = createSettingsManager(client);
const domain = new URL(saleorApiUrl).host;
if (req.method === "GET") {
logger.debug("Returning configuration");
await sendResponse(res, 200, settings, domain);
return;
} else if (req.method === "POST") {
logger.debug("Updating the configuration");
const { appId, secretKey, indexNamePrefix } = JSON.parse(req.body) as AppConfigurationFields;
const { data: channelsData } = await client.query(ChannelsDocument, {}).toPromise();
const channels = channelsData?.channels || [];
const algoliaClient = new AlgoliaSearchProvider({
appId,
apiKey: secretKey,
indexNamePrefix: indexNamePrefix,
channels,
});
try {
logger.debug("Will ping Algolia");
await algoliaClient.ping();
logger.debug("Algolia connection is ok. Will save settings");
await settings.set([
{ key: "secretKey", value: secretKey || "", domain },
{ key: "appId", value: appId || "", domain },
{ key: "indexNamePrefix", value: indexNamePrefix || "", domain },
]);
logger.debug("Settings set");
const webhooksToggler = new WebhookActivityTogglerService(ctx.authData.appId, client);
await webhooksToggler.enableOwnWebhooks();
logger.debug("Webhooks enabled");
} catch (e) {
return res.status(400).end();
}
await sendResponse(res, 200, settings, domain);
return;
}
logger.error("Method not supported");
res.status(405).end();
};
export default createProtectedHandler(handler, saleorApp.apl, ["MANAGE_APPS", "MANAGE_PRODUCTS"]);

View file

@ -20,7 +20,7 @@ const logger = createLogger({
* Simple dependency injection - factory injects all services, in tests everything can be configured without mocks * Simple dependency injection - factory injects all services, in tests everything can be configured without mocks
*/ */
type FactoryProps = { type FactoryProps = {
settingsManagerFactory: (client: Client) => SettingsManager; settingsManagerFactory: (client: Client, appId: string) => SettingsManager;
webhookActivityTogglerFactory: (appId: string, client: Client) => IWebhookActivityTogglerService; webhookActivityTogglerFactory: (appId: string, client: Client) => IWebhookActivityTogglerService;
graphqlClientFactory: (saleorApiUrl: string, token: string) => Client; graphqlClientFactory: (saleorApiUrl: string, token: string) => Client;
}; };
@ -40,7 +40,7 @@ export const recreateWebhooksHandlerFactory =
logger.debug("Fetching settings"); logger.debug("Fetching settings");
const client = graphqlClientFactory(authData.saleorApiUrl, authData.token); const client = graphqlClientFactory(authData.saleorApiUrl, authData.token);
const webhooksToggler = webhookActivityTogglerFactory(authData.appId, client); const webhooksToggler = webhookActivityTogglerFactory(authData.appId, client);
const settingsManager = settingsManagerFactory(client); const settingsManager = settingsManagerFactory(client, authData.appId);
const domain = new URL(authData.saleorApiUrl).host; const domain = new URL(authData.saleorApiUrl).host;

View file

@ -16,7 +16,7 @@ const logger = createLogger({
* Simple dependency injection - factory injects all services, in tests everything can be configured without mocks * Simple dependency injection - factory injects all services, in tests everything can be configured without mocks
*/ */
type FactoryProps = { type FactoryProps = {
settingsManagerFactory: (client: Client) => SettingsManager; settingsManagerFactory: (client: Client, appId: string) => SettingsManager;
graphqlClientFactory: (saleorApiUrl: string, token: string) => Client; graphqlClientFactory: (saleorApiUrl: string, token: string) => Client;
}; };
@ -30,7 +30,7 @@ export const setupIndicesHandlerFactory =
logger.debug("Fetching settings"); logger.debug("Fetching settings");
const client = graphqlClientFactory(authData.saleorApiUrl, authData.token); const client = graphqlClientFactory(authData.saleorApiUrl, authData.token);
const settingsManager = settingsManagerFactory(client); const settingsManager = settingsManagerFactory(client, authData.appId);
const domain = new URL(authData.saleorApiUrl).host; const domain = new URL(authData.saleorApiUrl).host;

View file

@ -0,0 +1,8 @@
import * as trpcNext from "@trpc/server/adapters/next";
import { createTrpcContext } from "../../../modules/trpc/trpc-context";
import { appRouter } from "../../../modules/trpc/trpc-app-router";
export default trpcNext.createNextApiHandler({
router: appRouter,
createContext: createTrpcContext,
});

View file

@ -22,10 +22,16 @@ const logger = createLogger({
* Simple dependency injection - factory injects all services, in tests everything can be configured without mocks * Simple dependency injection - factory injects all services, in tests everything can be configured without mocks
*/ */
type FactoryProps = { type FactoryProps = {
settingsManagerFactory: (client: Client) => SettingsManager; settingsManagerFactory: (
webhookActivityTogglerFactory: (appId: string, client: Client) => IWebhookActivityTogglerService; client: Pick<Client, "query" | "mutation">,
appId: string,
) => SettingsManager;
webhookActivityTogglerFactory: (
appId: string,
client: Pick<Client, "query" | "mutation">,
) => IWebhookActivityTogglerService;
algoliaSearchProviderFactory: (appId: string, apiKey: string) => Pick<SearchProvider, "ping">; algoliaSearchProviderFactory: (appId: string, apiKey: string) => Pick<SearchProvider, "ping">;
graphqlClientFactory: (saleorApiUrl: string, token: string) => Client; graphqlClientFactory: (saleorApiUrl: string, token: string) => Pick<Client, "query" | "mutation">;
}; };
export type WebhooksStatusResponse = { export type WebhooksStatusResponse = {
@ -46,7 +52,7 @@ export const webhooksStatusHandlerFactory =
*/ */
const client = graphqlClientFactory(authData.saleorApiUrl, authData.token); const client = graphqlClientFactory(authData.saleorApiUrl, authData.token);
const webhooksToggler = webhookActivityTogglerFactory(authData.appId, client); const webhooksToggler = webhookActivityTogglerFactory(authData.appId, client);
const settingsManager = settingsManagerFactory(client); const settingsManager = settingsManagerFactory(client, authData.appId);
const domain = new URL(authData.saleorApiUrl).host; const domain = new URL(authData.saleorApiUrl).host;

View file

@ -28,6 +28,7 @@ const mockWebhookContext = {
const appWebhooksResponseData: Pick<OperationResult<FetchOwnWebhooksQuery, any>, "data"> = { const appWebhooksResponseData: Pick<OperationResult<FetchOwnWebhooksQuery, any>, "data"> = {
data: { data: {
app: { app: {
id: "appID",
webhooks: [ webhooks: [
{ {
name: "W1", name: "W1",
@ -39,6 +40,7 @@ const appWebhooksResponseData: Pick<OperationResult<FetchOwnWebhooksQuery, any>,
eventDeliveries: { eventDeliveries: {
edges: [], edges: [],
}, },
targetUrl: "localhost:3000/api/webhooks/test",
}, },
], ],
}, },

View file

@ -19,7 +19,9 @@
"NEXT_PUBLIC_SENTRY_DSN", "NEXT_PUBLIC_SENTRY_DSN",
"SENTRY_ENVIRONMENT", "SENTRY_ENVIRONMENT",
"APP_IFRAME_BASE_URL", "APP_IFRAME_BASE_URL",
"APP_API_BASE_URL" "APP_API_BASE_URL",
"VERCEL_URL",
"PORT"
] ]
} }
} }

View file

@ -107,6 +107,7 @@ async function updatePrivateMetadata(
export class EncryptedMetadataManagerFactory { export class EncryptedMetadataManagerFactory {
constructor(private encryptionKey: string) { constructor(private encryptionKey: string) {
if (!encryptionKey) { if (!encryptionKey) {
console.error("Encryption key is required, will throw");
throw new Error("Encryption key is required"); throw new Error("Encryption key is required");
} }
} }

View file

@ -1,9 +1,5 @@
lockfileVersion: '6.0' lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers: importers:
.: .:
@ -1053,6 +1049,18 @@ importers:
'@tanstack/react-query': '@tanstack/react-query':
specifier: 4.29.19 specifier: 4.29.19
version: 4.29.19(react-dom@18.2.0)(react@18.2.0) version: 4.29.19(react-dom@18.2.0)(react@18.2.0)
'@trpc/client':
specifier: 10.34.0
version: 10.34.0(@trpc/server@10.34.0)
'@trpc/next':
specifier: 10.34.0
version: 10.34.0(@tanstack/react-query@4.29.19)(@trpc/client@10.34.0)(@trpc/react-query@10.34.0)(@trpc/server@10.34.0)(next@13.4.8)(react-dom@18.2.0)(react@18.2.0)
'@trpc/react-query':
specifier: 10.34.0
version: 10.34.0(@tanstack/react-query@4.29.19)(@trpc/client@10.34.0)(@trpc/server@10.34.0)(react-dom@18.2.0)(react@18.2.0)
'@trpc/server':
specifier: 10.34.0
version: 10.34.0
'@types/debug': '@types/debug':
specifier: ^4.1.7 specifier: ^4.1.7
version: 4.1.8 version: 4.1.8
@ -5661,6 +5669,7 @@ packages:
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
dependencies: dependencies:
regenerator-runtime: 0.14.0 regenerator-runtime: 0.14.0
dev: true
/@babel/runtime@7.22.11: /@babel/runtime@7.22.11:
resolution: {integrity: sha512-ee7jVNlWN09+KftVOu9n7S8gQzD/Z6hN/I8VBRXW4P1+Xe7kJGXMwu8vds4aGIMHZnNbdpSWCfZZtinytpcAvA==} resolution: {integrity: sha512-ee7jVNlWN09+KftVOu9n7S8gQzD/Z6hN/I8VBRXW4P1+Xe7kJGXMwu8vds4aGIMHZnNbdpSWCfZZtinytpcAvA==}
@ -5794,7 +5803,7 @@ packages:
/@changesets/apply-release-plan@6.1.4: /@changesets/apply-release-plan@6.1.4:
resolution: {integrity: sha512-FMpKF1fRlJyCZVYHr3CbinpZZ+6MwvOtWUuO8uo+svcATEoc1zRDcj23pAurJ2TZ/uVz1wFHH6K3NlACy0PLew==} resolution: {integrity: sha512-FMpKF1fRlJyCZVYHr3CbinpZZ+6MwvOtWUuO8uo+svcATEoc1zRDcj23pAurJ2TZ/uVz1wFHH6K3NlACy0PLew==}
dependencies: dependencies:
'@babel/runtime': 7.22.10 '@babel/runtime': 7.22.11
'@changesets/config': 2.3.1 '@changesets/config': 2.3.1
'@changesets/get-version-range-type': 0.3.2 '@changesets/get-version-range-type': 0.3.2
'@changesets/git': 2.0.0 '@changesets/git': 2.0.0
@ -5811,7 +5820,7 @@ packages:
/@changesets/assemble-release-plan@5.2.4: /@changesets/assemble-release-plan@5.2.4:
resolution: {integrity: sha512-xJkWX+1/CUaOUWTguXEbCDTyWJFECEhmdtbkjhn5GVBGxdP/JwaHBIU9sW3FR6gD07UwZ7ovpiPclQZs+j+mvg==} resolution: {integrity: sha512-xJkWX+1/CUaOUWTguXEbCDTyWJFECEhmdtbkjhn5GVBGxdP/JwaHBIU9sW3FR6gD07UwZ7ovpiPclQZs+j+mvg==}
dependencies: dependencies:
'@babel/runtime': 7.22.10 '@babel/runtime': 7.22.11
'@changesets/errors': 0.1.4 '@changesets/errors': 0.1.4
'@changesets/get-dependents-graph': 1.3.6 '@changesets/get-dependents-graph': 1.3.6
'@changesets/types': 5.2.1 '@changesets/types': 5.2.1
@ -5889,7 +5898,7 @@ packages:
/@changesets/get-release-plan@3.0.17: /@changesets/get-release-plan@3.0.17:
resolution: {integrity: sha512-6IwKTubNEgoOZwDontYc2x2cWXfr6IKxP3IhKeK+WjyD6y3M4Gl/jdQvBw+m/5zWILSOCAaGLu2ZF6Q+WiPniw==} resolution: {integrity: sha512-6IwKTubNEgoOZwDontYc2x2cWXfr6IKxP3IhKeK+WjyD6y3M4Gl/jdQvBw+m/5zWILSOCAaGLu2ZF6Q+WiPniw==}
dependencies: dependencies:
'@babel/runtime': 7.22.10 '@babel/runtime': 7.22.11
'@changesets/assemble-release-plan': 5.2.4 '@changesets/assemble-release-plan': 5.2.4
'@changesets/config': 2.3.1 '@changesets/config': 2.3.1
'@changesets/pre': 1.0.14 '@changesets/pre': 1.0.14
@ -5903,7 +5912,7 @@ packages:
/@changesets/git@2.0.0: /@changesets/git@2.0.0:
resolution: {integrity: sha512-enUVEWbiqUTxqSnmesyJGWfzd51PY4H7mH9yUw0hPVpZBJ6tQZFMU3F3mT/t9OJ/GjyiM4770i+sehAn6ymx6A==} resolution: {integrity: sha512-enUVEWbiqUTxqSnmesyJGWfzd51PY4H7mH9yUw0hPVpZBJ6tQZFMU3F3mT/t9OJ/GjyiM4770i+sehAn6ymx6A==}
dependencies: dependencies:
'@babel/runtime': 7.22.10 '@babel/runtime': 7.22.11
'@changesets/errors': 0.1.4 '@changesets/errors': 0.1.4
'@changesets/types': 5.2.1 '@changesets/types': 5.2.1
'@manypkg/get-packages': 1.1.3 '@manypkg/get-packages': 1.1.3
@ -5925,7 +5934,7 @@ packages:
/@changesets/pre@1.0.14: /@changesets/pre@1.0.14:
resolution: {integrity: sha512-dTsHmxQWEQekHYHbg+M1mDVYFvegDh9j/kySNuDKdylwfMEevTeDouR7IfHNyVodxZXu17sXoJuf2D0vi55FHQ==} resolution: {integrity: sha512-dTsHmxQWEQekHYHbg+M1mDVYFvegDh9j/kySNuDKdylwfMEevTeDouR7IfHNyVodxZXu17sXoJuf2D0vi55FHQ==}
dependencies: dependencies:
'@babel/runtime': 7.22.10 '@babel/runtime': 7.22.11
'@changesets/errors': 0.1.4 '@changesets/errors': 0.1.4
'@changesets/types': 5.2.1 '@changesets/types': 5.2.1
'@manypkg/get-packages': 1.1.3 '@manypkg/get-packages': 1.1.3
@ -5934,7 +5943,7 @@ packages:
/@changesets/read@0.5.9: /@changesets/read@0.5.9:
resolution: {integrity: sha512-T8BJ6JS6j1gfO1HFq50kU3qawYxa4NTbI/ASNVVCBTsKquy2HYwM9r7ZnzkiMe8IEObAJtUVGSrePCOxAK2haQ==} resolution: {integrity: sha512-T8BJ6JS6j1gfO1HFq50kU3qawYxa4NTbI/ASNVVCBTsKquy2HYwM9r7ZnzkiMe8IEObAJtUVGSrePCOxAK2haQ==}
dependencies: dependencies:
'@babel/runtime': 7.22.10 '@babel/runtime': 7.22.11
'@changesets/git': 2.0.0 '@changesets/git': 2.0.0
'@changesets/logger': 0.0.5 '@changesets/logger': 0.0.5
'@changesets/parse': 0.3.16 '@changesets/parse': 0.3.16
@ -5952,7 +5961,7 @@ packages:
/@changesets/write@0.2.3: /@changesets/write@0.2.3:
resolution: {integrity: sha512-Dbamr7AIMvslKnNYsLFafaVORx4H0pvCA2MHqgtNCySMe1blImEyAEOzDmcgKAkgz4+uwoLz7demIrX+JBr/Xw==} resolution: {integrity: sha512-Dbamr7AIMvslKnNYsLFafaVORx4H0pvCA2MHqgtNCySMe1blImEyAEOzDmcgKAkgz4+uwoLz7demIrX+JBr/Xw==}
dependencies: dependencies:
'@babel/runtime': 7.22.10 '@babel/runtime': 7.22.11
'@changesets/types': 5.2.1 '@changesets/types': 5.2.1
fs-extra: 7.0.1 fs-extra: 7.0.1
human-id: 1.0.2 human-id: 1.0.2
@ -7693,7 +7702,7 @@ packages:
/@manypkg/get-packages@1.1.3: /@manypkg/get-packages@1.1.3:
resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==}
dependencies: dependencies:
'@babel/runtime': 7.22.10 '@babel/runtime': 7.22.11
'@changesets/types': 4.1.0 '@changesets/types': 4.1.0
'@manypkg/find-root': 1.1.0 '@manypkg/find-root': 1.1.0
fs-extra: 8.1.0 fs-extra: 8.1.0
@ -21366,3 +21375,7 @@ packages:
/zod@3.21.4: /zod@3.21.4:
resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==} resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==}
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false