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:
parent
2a1385bab1
commit
0f84985c98
24 changed files with 424 additions and 302 deletions
7
.changeset/sixty-ligers-fold.md
Normal file
7
.changeset/sixty-ligers-fold.md
Normal 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_
|
|
@ -20,6 +20,10 @@
|
|||
"@saleor/react-hook-form-macaw": "workspace:*",
|
||||
"@sentry/nextjs": "7.55.2",
|
||||
"@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",
|
||||
"@urql/exchange-auth": "^2.1.4",
|
||||
"algoliasearch": "4.19.1",
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
import { useAuthenticatedFetch } from "@saleor/app-sdk/app-bridge";
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchConfiguration } from "../lib/configuration";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||
import { Box, Button, Divider, Text } from "@saleor/macaw-ui/next";
|
||||
import { Input } from "@saleor/react-hook-form-macaw";
|
||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||
import { AlgoliaSearchProvider } from "../lib/algolia/algoliaSearchProvider";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { AppConfigurationFields, AppConfigurationSchema } from "../domain/configuration";
|
||||
import { AlgoliaSearchProvider } from "../lib/algolia/algoliaSearchProvider";
|
||||
import { trpcClient } from "../modules/trpc/trpc-client";
|
||||
|
||||
export const AlgoliaConfigurationForm = () => {
|
||||
const { notifyError, notifySuccess } = useDashboardNotification();
|
||||
|
@ -24,50 +24,32 @@ export const AlgoliaConfigurationForm = () => {
|
|||
});
|
||||
|
||||
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(
|
||||
async (conf: AppConfigurationFields) => {
|
||||
const resp = await fetch("/api/configuration", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(conf),
|
||||
});
|
||||
const { isLoading: isQueryLoading, refetch: refetchConfig } =
|
||||
trpcClient.configuration.getConfig.useQuery(undefined, {
|
||||
onSuccess(data) {
|
||||
setValue("secretKey", data?.secretKey || "");
|
||||
setValue("appId", data?.appId || "");
|
||||
setValue("indexNamePrefix", data?.indexNamePrefix || "");
|
||||
},
|
||||
});
|
||||
|
||||
if (resp.status >= 200 && resp.status < 300) {
|
||||
const data = (await resp.json()) as { data?: AppConfigurationFields };
|
||||
|
||||
return data.data;
|
||||
}
|
||||
throw new Error(`Server responded with status code ${resp.status}`);
|
||||
},
|
||||
{
|
||||
const { mutate: setConfig, isLoading: isMutationLoading } =
|
||||
trpcClient.configuration.setConfig.useMutation({
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
reactQueryClient.refetchQueries({
|
||||
queryKey: ["configuration"],
|
||||
}),
|
||||
refetchConfig(),
|
||||
// todo migrate to trpc
|
||||
reactQueryClient.refetchQueries({
|
||||
queryKey: ["webhooks-status"],
|
||||
}),
|
||||
]);
|
||||
notifySuccess("Configuration saved!");
|
||||
},
|
||||
onError: async (data: Error) => {
|
||||
notifyError("Could not save the configuration", data.message);
|
||||
onError: async (error) => {
|
||||
notifyError("Could not save the configuration", error.message);
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const onFormSubmit = handleSubmit(async (conf) => {
|
||||
const client = new AlgoliaSearchProvider({
|
||||
|
@ -80,7 +62,7 @@ export const AlgoliaConfigurationForm = () => {
|
|||
await client.ping();
|
||||
setCredentialsValidationError(false);
|
||||
|
||||
mutate(conf);
|
||||
setConfig(conf);
|
||||
} catch (e) {
|
||||
trigger();
|
||||
setCredentialsValidationError(true);
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AlgoliaSearchProvider } from "../lib/algolia/algoliaSearchProvider";
|
||||
import { useConfiguration } from "../lib/configuration";
|
||||
import { Products, useQueryAllProducts } from "./useQueryAllProducts";
|
||||
import { trpcClient } from "../modules/trpc/trpc-client";
|
||||
|
||||
const BATCH_SIZE = 100;
|
||||
|
||||
|
@ -14,21 +14,21 @@ export const ImportProductsToAlgolia = () => {
|
|||
|
||||
const products = useQueryAllProducts(!started);
|
||||
|
||||
const algoliaConfiguration = useConfiguration();
|
||||
const { data: algoliaConfiguration } = trpcClient.configuration.getConfig.useQuery();
|
||||
|
||||
const searchProvider = useMemo(() => {
|
||||
if (!algoliaConfiguration.data?.appId || !algoliaConfiguration.data.secretKey) {
|
||||
if (!algoliaConfiguration?.appId || !algoliaConfiguration.secretKey) {
|
||||
return null;
|
||||
}
|
||||
return new AlgoliaSearchProvider({
|
||||
appId: algoliaConfiguration.data.appId,
|
||||
apiKey: algoliaConfiguration.data.secretKey,
|
||||
indexNamePrefix: algoliaConfiguration.data.indexNamePrefix,
|
||||
appId: algoliaConfiguration.appId,
|
||||
apiKey: algoliaConfiguration.secretKey,
|
||||
indexNamePrefix: algoliaConfiguration.indexNamePrefix,
|
||||
});
|
||||
}, [
|
||||
algoliaConfiguration?.data?.appId,
|
||||
algoliaConfiguration?.data?.indexNamePrefix,
|
||||
algoliaConfiguration?.data?.secretKey,
|
||||
algoliaConfiguration?.appId,
|
||||
algoliaConfiguration?.indexNamePrefix,
|
||||
algoliaConfiguration?.secretKey,
|
||||
]);
|
||||
|
||||
const importProducts = useCallback(() => {
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||
import React from "react";
|
||||
import { useConfiguration } from "../lib/configuration";
|
||||
import { TextLink } from "@saleor/apps-ui";
|
||||
import { useIndicesSetupMutation } from "../lib/useIndicesSetup";
|
||||
import { trpcClient } from "../modules/trpc/trpc-client";
|
||||
|
||||
export const IndicesSettings = () => {
|
||||
const algoliaConfiguration = useConfiguration();
|
||||
const { data: algoliaConfiguration } = trpcClient.configuration.getConfig.useQuery();
|
||||
const updateWebhooksMutation = useIndicesSetupMutation();
|
||||
|
||||
const isConfigured = algoliaConfiguration.data?.appId && algoliaConfiguration.data?.secretKey;
|
||||
const isConfigured = algoliaConfiguration?.appId && algoliaConfiguration?.secretKey;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
|
|
|
@ -15,7 +15,7 @@ export const getAlgoliaConfiguration = async ({ authData }: GetAlgoliaConfigurat
|
|||
token: authData.token,
|
||||
});
|
||||
|
||||
const settings = createSettingsManager(client);
|
||||
const settings = createSettingsManager(client, authData.appId);
|
||||
|
||||
try {
|
||||
const secretKey = await settings.get("secretKey", authData.domain);
|
||||
|
|
|
@ -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),
|
||||
});
|
||||
};
|
|
@ -1,91 +1,12 @@
|
|||
import {
|
||||
EncryptedMetadataManager,
|
||||
MetadataEntry,
|
||||
SettingsManager,
|
||||
} from "@saleor/app-sdk/settings-manager";
|
||||
|
||||
import {
|
||||
FetchAppDetailsDocument,
|
||||
FetchAppDetailsQuery,
|
||||
UpdateAppMetadataDocument,
|
||||
} from "../../generated/graphql";
|
||||
import { settingsManagerSecretKey } from "../../saleor-app";
|
||||
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
|
||||
import { EncryptedMetadataManagerFactory } from "@saleor/apps-shared";
|
||||
import { Client } from "urql";
|
||||
|
||||
/*
|
||||
* Function is using urql graphql client to fetch all available metadata.
|
||||
* Before returning query result, we are transforming response to list of objects with key and value fields
|
||||
* which can be used by the manager.
|
||||
* Result of this query is cached by the manager.
|
||||
*/
|
||||
export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]> {
|
||||
const { error, data } = await client
|
||||
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
|
||||
.toPromise();
|
||||
export const createSettingsManager = (
|
||||
client: Pick<Client, "query" | "mutation">,
|
||||
appId: string,
|
||||
): SettingsManager => {
|
||||
const metadataManagerFactory = new EncryptedMetadataManagerFactory(process.env.SECRET_KEY!);
|
||||
|
||||
if (error) {
|
||||
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),
|
||||
});
|
||||
return metadataManagerFactory.create(client, appId);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}),
|
||||
});
|
117
apps/search/src/modules/trpc/protected-client-procedure.ts
Normal file
117
apps/search/src/modules/trpc/protected-client-procedure.ts
Normal 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!,
|
||||
},
|
||||
});
|
||||
});
|
8
apps/search/src/modules/trpc/trpc-app-router.ts
Normal file
8
apps/search/src/modules/trpc/trpc-app-router.ts
Normal 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;
|
45
apps/search/src/modules/trpc/trpc-client.ts
Normal file
45
apps/search/src/modules/trpc/trpc-client.ts
Normal 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,
|
||||
});
|
30
apps/search/src/modules/trpc/trpc-context.ts
Normal file
30
apps/search/src/modules/trpc/trpc-context.ts
Normal 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>;
|
31
apps/search/src/modules/trpc/trpc-server.ts
Normal file
31
apps/search/src/modules/trpc/trpc-server.ts
Normal 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;
|
|
@ -9,12 +9,13 @@ import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next";
|
|||
import { ThemeSynchronizer } from "../lib/theme-synchronizer";
|
||||
import { Box, ThemeProvider } from "@saleor/macaw-ui/next";
|
||||
import { NoSSRWrapper } from "@saleor/apps-shared";
|
||||
import { trpcClient } from "../modules/trpc/trpc-client";
|
||||
|
||||
/**
|
||||
* Ensure instance is a singleton.
|
||||
* 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({
|
||||
defaultOptions: {
|
||||
|
@ -44,4 +45,4 @@ function NextApp({ Component, pageProps }: AppProps) {
|
|||
);
|
||||
}
|
||||
|
||||
export default NextApp;
|
||||
export default trpcClient.withTRPC(NextApp);
|
||||
|
|
|
@ -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"]);
|
|
@ -20,7 +20,7 @@ const logger = createLogger({
|
|||
* Simple dependency injection - factory injects all services, in tests everything can be configured without mocks
|
||||
*/
|
||||
type FactoryProps = {
|
||||
settingsManagerFactory: (client: Client) => SettingsManager;
|
||||
settingsManagerFactory: (client: Client, appId: string) => SettingsManager;
|
||||
webhookActivityTogglerFactory: (appId: string, client: Client) => IWebhookActivityTogglerService;
|
||||
graphqlClientFactory: (saleorApiUrl: string, token: string) => Client;
|
||||
};
|
||||
|
@ -40,7 +40,7 @@ export const recreateWebhooksHandlerFactory =
|
|||
logger.debug("Fetching settings");
|
||||
const client = graphqlClientFactory(authData.saleorApiUrl, authData.token);
|
||||
const webhooksToggler = webhookActivityTogglerFactory(authData.appId, client);
|
||||
const settingsManager = settingsManagerFactory(client);
|
||||
const settingsManager = settingsManagerFactory(client, authData.appId);
|
||||
|
||||
const domain = new URL(authData.saleorApiUrl).host;
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@ const logger = createLogger({
|
|||
* Simple dependency injection - factory injects all services, in tests everything can be configured without mocks
|
||||
*/
|
||||
type FactoryProps = {
|
||||
settingsManagerFactory: (client: Client) => SettingsManager;
|
||||
settingsManagerFactory: (client: Client, appId: string) => SettingsManager;
|
||||
graphqlClientFactory: (saleorApiUrl: string, token: string) => Client;
|
||||
};
|
||||
|
||||
|
@ -30,7 +30,7 @@ export const setupIndicesHandlerFactory =
|
|||
|
||||
logger.debug("Fetching settings");
|
||||
const client = graphqlClientFactory(authData.saleorApiUrl, authData.token);
|
||||
const settingsManager = settingsManagerFactory(client);
|
||||
const settingsManager = settingsManagerFactory(client, authData.appId);
|
||||
|
||||
const domain = new URL(authData.saleorApiUrl).host;
|
||||
|
||||
|
|
8
apps/search/src/pages/api/trpc/[trpc].ts
Normal file
8
apps/search/src/pages/api/trpc/[trpc].ts
Normal 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,
|
||||
});
|
|
@ -22,10 +22,16 @@ const logger = createLogger({
|
|||
* Simple dependency injection - factory injects all services, in tests everything can be configured without mocks
|
||||
*/
|
||||
type FactoryProps = {
|
||||
settingsManagerFactory: (client: Client) => SettingsManager;
|
||||
webhookActivityTogglerFactory: (appId: string, client: Client) => IWebhookActivityTogglerService;
|
||||
settingsManagerFactory: (
|
||||
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">;
|
||||
graphqlClientFactory: (saleorApiUrl: string, token: string) => Client;
|
||||
graphqlClientFactory: (saleorApiUrl: string, token: string) => Pick<Client, "query" | "mutation">;
|
||||
};
|
||||
|
||||
export type WebhooksStatusResponse = {
|
||||
|
@ -46,7 +52,7 @@ export const webhooksStatusHandlerFactory =
|
|||
*/
|
||||
const client = graphqlClientFactory(authData.saleorApiUrl, authData.token);
|
||||
const webhooksToggler = webhookActivityTogglerFactory(authData.appId, client);
|
||||
const settingsManager = settingsManagerFactory(client);
|
||||
const settingsManager = settingsManagerFactory(client, authData.appId);
|
||||
|
||||
const domain = new URL(authData.saleorApiUrl).host;
|
||||
|
||||
|
|
|
@ -28,6 +28,7 @@ const mockWebhookContext = {
|
|||
const appWebhooksResponseData: Pick<OperationResult<FetchOwnWebhooksQuery, any>, "data"> = {
|
||||
data: {
|
||||
app: {
|
||||
id: "appID",
|
||||
webhooks: [
|
||||
{
|
||||
name: "W1",
|
||||
|
@ -39,6 +40,7 @@ const appWebhooksResponseData: Pick<OperationResult<FetchOwnWebhooksQuery, any>,
|
|||
eventDeliveries: {
|
||||
edges: [],
|
||||
},
|
||||
targetUrl: "localhost:3000/api/webhooks/test",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
@ -19,7 +19,9 @@
|
|||
"NEXT_PUBLIC_SENTRY_DSN",
|
||||
"SENTRY_ENVIRONMENT",
|
||||
"APP_IFRAME_BASE_URL",
|
||||
"APP_API_BASE_URL"
|
||||
"APP_API_BASE_URL",
|
||||
"VERCEL_URL",
|
||||
"PORT"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -107,6 +107,7 @@ async function updatePrivateMetadata(
|
|||
export class EncryptedMetadataManagerFactory {
|
||||
constructor(private encryptionKey: string) {
|
||||
if (!encryptionKey) {
|
||||
console.error("Encryption key is required, will throw");
|
||||
throw new Error("Encryption key is required");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,5 @@
|
|||
lockfileVersion: '6.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
|
@ -1053,6 +1049,18 @@ importers:
|
|||
'@tanstack/react-query':
|
||||
specifier: 4.29.19
|
||||
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':
|
||||
specifier: ^4.1.7
|
||||
version: 4.1.8
|
||||
|
@ -5661,6 +5669,7 @@ packages:
|
|||
engines: {node: '>=6.9.0'}
|
||||
dependencies:
|
||||
regenerator-runtime: 0.14.0
|
||||
dev: true
|
||||
|
||||
/@babel/runtime@7.22.11:
|
||||
resolution: {integrity: sha512-ee7jVNlWN09+KftVOu9n7S8gQzD/Z6hN/I8VBRXW4P1+Xe7kJGXMwu8vds4aGIMHZnNbdpSWCfZZtinytpcAvA==}
|
||||
|
@ -5794,7 +5803,7 @@ packages:
|
|||
/@changesets/apply-release-plan@6.1.4:
|
||||
resolution: {integrity: sha512-FMpKF1fRlJyCZVYHr3CbinpZZ+6MwvOtWUuO8uo+svcATEoc1zRDcj23pAurJ2TZ/uVz1wFHH6K3NlACy0PLew==}
|
||||
dependencies:
|
||||
'@babel/runtime': 7.22.10
|
||||
'@babel/runtime': 7.22.11
|
||||
'@changesets/config': 2.3.1
|
||||
'@changesets/get-version-range-type': 0.3.2
|
||||
'@changesets/git': 2.0.0
|
||||
|
@ -5811,7 +5820,7 @@ packages:
|
|||
/@changesets/assemble-release-plan@5.2.4:
|
||||
resolution: {integrity: sha512-xJkWX+1/CUaOUWTguXEbCDTyWJFECEhmdtbkjhn5GVBGxdP/JwaHBIU9sW3FR6gD07UwZ7ovpiPclQZs+j+mvg==}
|
||||
dependencies:
|
||||
'@babel/runtime': 7.22.10
|
||||
'@babel/runtime': 7.22.11
|
||||
'@changesets/errors': 0.1.4
|
||||
'@changesets/get-dependents-graph': 1.3.6
|
||||
'@changesets/types': 5.2.1
|
||||
|
@ -5889,7 +5898,7 @@ packages:
|
|||
/@changesets/get-release-plan@3.0.17:
|
||||
resolution: {integrity: sha512-6IwKTubNEgoOZwDontYc2x2cWXfr6IKxP3IhKeK+WjyD6y3M4Gl/jdQvBw+m/5zWILSOCAaGLu2ZF6Q+WiPniw==}
|
||||
dependencies:
|
||||
'@babel/runtime': 7.22.10
|
||||
'@babel/runtime': 7.22.11
|
||||
'@changesets/assemble-release-plan': 5.2.4
|
||||
'@changesets/config': 2.3.1
|
||||
'@changesets/pre': 1.0.14
|
||||
|
@ -5903,7 +5912,7 @@ packages:
|
|||
/@changesets/git@2.0.0:
|
||||
resolution: {integrity: sha512-enUVEWbiqUTxqSnmesyJGWfzd51PY4H7mH9yUw0hPVpZBJ6tQZFMU3F3mT/t9OJ/GjyiM4770i+sehAn6ymx6A==}
|
||||
dependencies:
|
||||
'@babel/runtime': 7.22.10
|
||||
'@babel/runtime': 7.22.11
|
||||
'@changesets/errors': 0.1.4
|
||||
'@changesets/types': 5.2.1
|
||||
'@manypkg/get-packages': 1.1.3
|
||||
|
@ -5925,7 +5934,7 @@ packages:
|
|||
/@changesets/pre@1.0.14:
|
||||
resolution: {integrity: sha512-dTsHmxQWEQekHYHbg+M1mDVYFvegDh9j/kySNuDKdylwfMEevTeDouR7IfHNyVodxZXu17sXoJuf2D0vi55FHQ==}
|
||||
dependencies:
|
||||
'@babel/runtime': 7.22.10
|
||||
'@babel/runtime': 7.22.11
|
||||
'@changesets/errors': 0.1.4
|
||||
'@changesets/types': 5.2.1
|
||||
'@manypkg/get-packages': 1.1.3
|
||||
|
@ -5934,7 +5943,7 @@ packages:
|
|||
/@changesets/read@0.5.9:
|
||||
resolution: {integrity: sha512-T8BJ6JS6j1gfO1HFq50kU3qawYxa4NTbI/ASNVVCBTsKquy2HYwM9r7ZnzkiMe8IEObAJtUVGSrePCOxAK2haQ==}
|
||||
dependencies:
|
||||
'@babel/runtime': 7.22.10
|
||||
'@babel/runtime': 7.22.11
|
||||
'@changesets/git': 2.0.0
|
||||
'@changesets/logger': 0.0.5
|
||||
'@changesets/parse': 0.3.16
|
||||
|
@ -5952,7 +5961,7 @@ packages:
|
|||
/@changesets/write@0.2.3:
|
||||
resolution: {integrity: sha512-Dbamr7AIMvslKnNYsLFafaVORx4H0pvCA2MHqgtNCySMe1blImEyAEOzDmcgKAkgz4+uwoLz7demIrX+JBr/Xw==}
|
||||
dependencies:
|
||||
'@babel/runtime': 7.22.10
|
||||
'@babel/runtime': 7.22.11
|
||||
'@changesets/types': 5.2.1
|
||||
fs-extra: 7.0.1
|
||||
human-id: 1.0.2
|
||||
|
@ -7693,7 +7702,7 @@ packages:
|
|||
/@manypkg/get-packages@1.1.3:
|
||||
resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==}
|
||||
dependencies:
|
||||
'@babel/runtime': 7.22.10
|
||||
'@babel/runtime': 7.22.11
|
||||
'@changesets/types': 4.1.0
|
||||
'@manypkg/find-root': 1.1.0
|
||||
fs-extra: 8.1.0
|
||||
|
@ -21366,3 +21375,7 @@ packages:
|
|||
|
||||
/zod@3.21.4:
|
||||
resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==}
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
|
Loading…
Reference in a new issue