Product feed: Fix title template form (#882)

* Improve logging

* Fix error on parsing metadata created in the previous version

* Fix title template form

* Add changesets

* Use info level instead of debug
This commit is contained in:
Krzysztof Wolski 2023-08-10 17:37:59 +02:00 committed by GitHub
parent 7b19ab44c3
commit bb504d95aa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 177 additions and 114 deletions

View file

@ -0,0 +1,5 @@
---
"saleor-app-products-feed": patch
---
Fixed error on loading configuration created in the previous version of the app.

View file

@ -0,0 +1,5 @@
---
"saleor-app-products-feed": patch
---
Fixed issue with saving changes in the title template form.

View file

@ -0,0 +1,5 @@
---
"saleor-app-products-feed": patch
---
Improved error logging in the tRPC API.

View file

@ -1,9 +1,43 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { AppConfig } from "./app-config"; import { AppConfig, RootConfig } from "./app-config";
const exampleChannelConfig: RootConfig["channelConfig"] = {
test: {
storefrontUrls: {
productStorefrontUrl: "https://example.com",
storefrontUrl: "https://example.com/p/{{ variant.product.slug }}",
},
},
};
const exampleS3Config: RootConfig["s3"] = {
accessKeyId: "example-access-key",
bucketName: "example-bucket-name",
region: "eu-west-1",
secretAccessKey: "example-secret-key",
};
const exampleAttributeMappingConfig: RootConfig["attributeMapping"] = {
brandAttributeIds: ["brand-attribute-1"],
colorAttributeIds: [],
patternAttributeIds: [],
materialAttributeIds: [],
sizeAttributeIds: [],
};
const exampleTitleTemplate: RootConfig["titleTemplate"] =
"Example {{ variant.product.name }} - {{ variant.name }}";
const exampleConfiguration: RootConfig = {
channelConfig: exampleChannelConfig,
s3: exampleS3Config,
attributeMapping: exampleAttributeMappingConfig,
titleTemplate: exampleTitleTemplate,
};
describe("AppConfig", function () { describe("AppConfig", function () {
describe("Construction", () => { describe("Construction", () => {
it("Constructs empty state", () => { it("Constructs configuration with default values, when empty object is passed as initial data", () => {
const instance = new AppConfig(); const instance = new AppConfig();
expect(instance.getRootConfig()).toEqual({ expect(instance.getRootConfig()).toEqual({
@ -20,47 +54,24 @@ describe("AppConfig", function () {
}); });
}); });
it("Constructs from initial state", () => { it("Constructs configuration, when valid initial state is passed", () => {
const instance = new AppConfig({ const instance = new AppConfig(exampleConfiguration);
s3: {
region: "region", expect(instance.getRootConfig()).toEqual(exampleConfiguration);
bucketName: "bucket", });
accessKeyId: "access",
secretAccessKey: "secret", it("Fill attribute mapping and title template with default values, when initial data are lacking those fields", () => {
}, const configurationWithoutMapping = structuredClone(exampleConfiguration);
channelConfig: {
test: { // @ts-expect-error: Simulating data before the migration
storefrontUrls: { delete configurationWithoutMapping.attributeMapping;
productStorefrontUrl: "https://example.com", // @ts-expect-error
storefrontUrl: "https://example.com/p/{{ variant.product.slug }}", delete configurationWithoutMapping.titleTemplate;
},
}, const instance = new AppConfig(configurationWithoutMapping as any); // Casting used to prevent TS from reporting an error
},
attributeMapping: {
brandAttributeIds: [],
colorAttributeIds: [],
patternAttributeIds: [],
materialAttributeIds: [],
sizeAttributeIds: [],
},
titleTemplate: "{{ variant.name }}",
});
expect(instance.getRootConfig()).toEqual({ expect(instance.getRootConfig()).toEqual({
s3: { ...exampleConfiguration,
bucketName: "bucket",
secretAccessKey: "secret",
accessKeyId: "access",
region: "region",
},
channelConfig: {
test: {
storefrontUrls: {
productStorefrontUrl: "https://example.com",
storefrontUrl: "https://example.com/p/{{ variant.product.slug }}",
},
},
},
attributeMapping: { attributeMapping: {
brandAttributeIds: [], brandAttributeIds: [],
colorAttributeIds: [], colorAttributeIds: [],
@ -68,7 +79,7 @@ describe("AppConfig", function () {
materialAttributeIds: [], materialAttributeIds: [],
sizeAttributeIds: [], sizeAttributeIds: [],
}, },
titleTemplate: "{{ variant.name }}", titleTemplate: "{{variant.product.name}} - {{variant.name}}",
}); });
}); });
@ -78,7 +89,7 @@ describe("AppConfig", function () {
new AppConfig({ new AppConfig({
// @ts-expect-error // @ts-expect-error
foo: "bar", foo: "bar",
}) }),
).toThrow(); ).toThrow();
}); });
@ -226,7 +237,7 @@ describe("AppConfig", function () {
}); });
// @ts-expect-error // @ts-expect-error
expect(() => instance.setS3({ foo: "bar" })).toThrow(); expect(() => instance.setS3({ foo: "bar" })).toThrowError();
}); });
it("setChannelUrls sets valid config to channelConfig[channelSlug] and rejects invalid config", () => { it("setChannelUrls sets valid config to channelConfig[channelSlug] and rejects invalid config", () => {
@ -243,7 +254,7 @@ describe("AppConfig", function () {
}); });
// @ts-expect-error // @ts-expect-error
expect(() => instance.setChannelUrls("channel", "foo")).toThrow(); expect(() => instance.setChannelUrls("channel", "foo")).toThrowError();
}); });
}); });

View file

@ -1,3 +1,4 @@
import { createLogger } from "@saleor/apps-shared";
import { z } from "zod"; import { z } from "zod";
const titleTemplateFieldSchema = z.string().default("{{variant.product.name}} - {{variant.name}}"); const titleTemplateFieldSchema = z.string().default("{{variant.product.name}} - {{variant.name}}");
@ -30,8 +31,13 @@ const urlConfigurationSchema = z.object({
const rootAppConfigSchema = z.object({ const rootAppConfigSchema = z.object({
s3: s3ConfigSchema.nullable(), s3: s3ConfigSchema.nullable(),
titleTemplate: titleTemplateFieldSchema, titleTemplate: titleTemplateFieldSchema
attributeMapping: attributeMappingSchema.nullable(), .optional()
.default(titleTemplateFieldSchema.parse(undefined)),
attributeMapping: attributeMappingSchema
.nullable()
.optional()
.default(attributeMappingSchema.parse({})),
channelConfig: z.record(z.object({ storefrontUrls: urlConfigurationSchema })), channelConfig: z.record(z.object({ storefrontUrls: urlConfigurationSchema })),
}); });
@ -46,6 +52,8 @@ export type RootConfig = z.infer<typeof rootAppConfigSchema>;
export type ChannelUrlsConfig = z.infer<typeof AppConfigSchema.channelUrls>; export type ChannelUrlsConfig = z.infer<typeof AppConfigSchema.channelUrls>;
const logger = createLogger({ name: "AppConfig" });
export class AppConfig { export class AppConfig {
private rootData: RootConfig = { private rootData: RootConfig = {
channelConfig: {}, channelConfig: {},
@ -56,7 +64,12 @@ export class AppConfig {
constructor(initialData?: RootConfig) { constructor(initialData?: RootConfig) {
if (initialData) { if (initialData) {
this.rootData = rootAppConfigSchema.parse(initialData); try {
this.rootData = rootAppConfigSchema.parse(initialData);
} catch (e) {
logger.error(e, "Could not parse initial data");
throw new Error("Can't load the configuration");
}
} }
} }
@ -78,8 +91,7 @@ export class AppConfig {
return this; return this;
} catch (e) { } catch (e) {
console.error(e); logger.info(e, "Invalid S3 config provided");
throw new Error("Invalid S3 config provided"); throw new Error("Invalid S3 config provided");
} }
} }
@ -90,8 +102,7 @@ export class AppConfig {
return this; return this;
} catch (e) { } catch (e) {
console.error(e); logger.info(e, "Invalid mapping config provided");
throw new Error("Invalid mapping config provided"); throw new Error("Invalid mapping config provided");
} }
} }
@ -106,9 +117,8 @@ export class AppConfig {
return this; return this;
} catch (e) { } catch (e) {
console.error(e); logger.info(e, "Invalid channels config provided");
throw new Error("Invalid channels config provided");
throw new Error("Invalid payload");
} }
} }

View file

@ -17,11 +17,19 @@ export const appConfigurationRouter = router({
* Prefer fetching all to avoid unnecessary calls. Routes are cached by react-query * Prefer fetching all to avoid unnecessary calls. Routes are cached by react-query
*/ */
fetch: protectedClientProcedure.query(async ({ ctx: { logger, getConfig } }) => { fetch: protectedClientProcedure.query(async ({ ctx: { logger, getConfig } }) => {
return getConfig().then((c) => { logger.debug("Fetching configuration");
logger.debug("Fetched config");
return c.getRootConfig(); try {
}); const configuration = await getConfig();
logger.debug("Configuration fetched");
return configuration.getRootConfig();
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Can't fetch the configuration",
});
}
}), }),
testS3BucketConfiguration: protectedClientProcedure testS3BucketConfiguration: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] }) .meta({ requiredClientPermissions: ["MANAGE_APPS"] })
@ -38,6 +46,7 @@ export const appConfigurationRouter = router({
bucketName: input.bucketName, bucketName: input.bucketName,
s3Client, s3Client,
}); });
logger.debug("Verification succeeded");
} catch { } catch {
logger.debug("Validation failed"); logger.debug("Validation failed");
throw new TRPCError({ throw new TRPCError({
@ -88,7 +97,7 @@ export const appConfigurationRouter = router({
z.object({ z.object({
channelSlug: z.string(), channelSlug: z.string(),
urls: AppConfigSchema.channelUrls, urls: AppConfigSchema.channelUrls,
}) }),
) )
.mutation( .mutation(
async ({ async ({
@ -117,7 +126,7 @@ export const appConfigurationRouter = router({
logger.debug("Saved config"); logger.debug("Saved config");
return null; return null;
} },
), ),
setAttributeMapping: protectedClientProcedure setAttributeMapping: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] }) .meta({ requiredClientPermissions: ["MANAGE_APPS"] })

View file

@ -5,12 +5,14 @@ import { ChannelFragment } from "../../../../generated/graphql";
export const channelsRouter = router({ export const channelsRouter = router({
fetch: protectedClientProcedure.query( fetch: protectedClientProcedure.query(
async ({ ctx: { logger, apiClient }, input }): Promise<ChannelFragment[]> => { async ({ ctx: { logger, apiClient } }): Promise<ChannelFragment[]> => {
const fetcher = new ChannelsFetcher(apiClient); const fetcher = new ChannelsFetcher(apiClient);
logger.debug("Will fetch channels"); logger.debug("Fetching channels");
const channels = fetcher.fetchChannels().then((channels) => channels ?? []);
return fetcher.fetchChannels().then((channels) => channels ?? []); logger.debug("Channels fetched successfully");
} return channels;
},
), ),
}); });

View file

@ -1,4 +1,4 @@
import { AppConfigSchema, TitleTemplateInput, titleTemplateInputSchema } from "./app-config"; import { TitleTemplateInput, titleTemplateInputSchema } from "./app-config";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { Box, Button, Text } from "@saleor/macaw-ui/next"; import { Box, Button, Text } from "@saleor/macaw-ui/next";
@ -19,7 +19,7 @@ type Props = {
export const TitleFormattingConfigurationForm = (props: Props) => { export const TitleFormattingConfigurationForm = (props: Props) => {
const { handleSubmit, control, getValues } = useForm<TitleTemplateInput>({ const { handleSubmit, control, getValues } = useForm<TitleTemplateInput>({
defaultValues: props.initialData, defaultValues: props.initialData,
resolver: zodResolver(AppConfigSchema.attributeMapping), resolver: zodResolver(titleTemplateInputSchema),
}); });
return ( return (
@ -55,12 +55,7 @@ export const ConnectedTitleFormattingForm = () => {
const { notifyError, notifySuccess } = useDashboardNotification(); const { notifyError, notifySuccess } = useDashboardNotification();
const [preview, setPreview] = useState<string | undefined>(); const [preview, setPreview] = useState<string | undefined>();
const { data: attributes, isLoading: isAttributesLoading } = const { data, isLoading } = trpcClient.appConfiguration.fetch.useQuery();
trpcClient.appConfiguration.getAttributes.useQuery();
const { data, isLoading: isConfigurationLoading } = trpcClient.appConfiguration.fetch.useQuery();
const isLoading = isAttributesLoading || isConfigurationLoading;
const { mutate } = trpcClient.appConfiguration.setTitleTemplate.useMutation({ const { mutate } = trpcClient.appConfiguration.setTitleTemplate.useMutation({
onSuccess() { onSuccess() {
@ -84,14 +79,14 @@ export const ConnectedTitleFormattingForm = () => {
async (data: TitleTemplateInput) => { async (data: TitleTemplateInput) => {
mutate(data); mutate(data);
}, },
[mutate] [mutate],
); );
const handlePreview = useCallback( const handlePreview = useCallback(
async (data: TitleTemplateInput) => { async (data: TitleTemplateInput) => {
previewMutate(data); previewMutate(data);
}, },
[previewMutate] [previewMutate],
); );
const formData: TitleTemplateInput = useMemo(() => { const formData: TitleTemplateInput = useMemo(() => {
@ -108,11 +103,9 @@ export const ConnectedTitleFormattingForm = () => {
return <Text>Loading...</Text>; return <Text>Loading...</Text>;
} }
const showForm = !isLoading && attributes?.length;
return ( return (
<> <>
{showForm ? ( {!isLoading ? (
<TitleFormattingConfigurationForm <TitleFormattingConfigurationForm
onSubmit={handleSubmit} onSubmit={handleSubmit}
initialData={formData} initialData={formData}

View file

@ -0,0 +1,32 @@
import { createLogger } from "@saleor/apps-shared";
import { middleware } from "./trpc-server";
export const attachLogger = middleware(async ({ ctx, next, type, path }) => {
const loggerName = `tRPC ${type} ${path.replace(/\./g, "/")}`;
const logger = createLogger({
name: loggerName,
requestType: type,
path,
saleorApiUrl: ctx.saleorApiUrl,
});
const start = Date.now();
logger.debug(`Requested received`);
const result = await next({
ctx: {
logger,
},
});
const durationMs = Date.now() - start;
if (result.ok) {
logger.debug({ durationMs }, `Response successful`);
} else {
logger.debug({ durationMs }, `Response with error`);
}
return result;
});

View file

@ -3,14 +3,15 @@ import { middleware, procedure } from "./trpc-server";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { ProtectedHandlerError } from "@saleor/app-sdk/handlers/next"; import { ProtectedHandlerError } from "@saleor/app-sdk/handlers/next";
import { saleorApp } from "../../saleor-app"; import { saleorApp } from "../../saleor-app";
import { createLogger, logger } from "@saleor/apps-shared"; import { createLogger } from "@saleor/apps-shared";
import { GraphqlClientFactory } from "../../lib/create-graphq-client"; import { GraphqlClientFactory } from "../../lib/create-graphq-client";
import { AppConfigMetadataManager } from "../app-configuration/app-config-metadata-manager"; import { AppConfigMetadataManager } from "../app-configuration/app-config-metadata-manager";
import { createSettingsManager } from "../../lib/metadata-manager"; import { createSettingsManager } from "../../lib/metadata-manager";
import { AppConfig } from "../app-configuration/app-config"; import { AppConfig } from "../app-configuration/app-config";
import { attachLogger } from "./middlewares";
const attachAppToken = middleware(async ({ ctx, next }) => { const attachAppToken = middleware(async ({ ctx, next }) => {
logger.debug("attachAppToken middleware"); const logger = createLogger({ name: "attachAppToken" });
if (!ctx.saleorApiUrl) { if (!ctx.saleorApiUrl) {
logger.debug("ctx.saleorApiUrl not found, throwing"); logger.debug("ctx.saleorApiUrl not found, throwing");
@ -21,6 +22,7 @@ const attachAppToken = middleware(async ({ ctx, next }) => {
}); });
} }
logger.debug("Getting auth data");
const authData = await saleorApp.apl.get(ctx.saleorApiUrl); const authData = await saleorApp.apl.get(ctx.saleorApiUrl);
if (!authData) { if (!authData) {
@ -31,6 +33,7 @@ const attachAppToken = middleware(async ({ ctx, next }) => {
message: "Missing auth data", message: "Missing auth data",
}); });
} }
logger.debug("Auth data found, attaching it to the context");
return next({ return next({
ctx: { ctx: {
@ -42,12 +45,7 @@ const attachAppToken = middleware(async ({ ctx, next }) => {
}); });
const validateClientToken = middleware(async ({ ctx, next, meta }) => { const validateClientToken = middleware(async ({ ctx, next, meta }) => {
logger.debug( const logger = createLogger({ name: "validateClientToken" });
{
permissions: meta?.requiredClientPermissions,
},
"Calling validateClientToken middleware with permissions required"
);
if (!ctx.token) { if (!ctx.token) {
throw new TRPCError({ throw new TRPCError({
@ -71,29 +69,27 @@ const validateClientToken = middleware(async ({ ctx, next, meta }) => {
}); });
} }
if (!ctx.ssr) { logger.debug(
try { {
logger.debug("trying to verify JWT token from frontend"); permissions: meta?.requiredClientPermissions,
logger.debug({ token: ctx.token ? `${ctx.token[0]}...` : undefined }); },
"Calling validateClientToken middleware with permissions required",
);
await verifyJWT({ try {
appId: ctx.appId, await verifyJWT({
token: ctx.token, appId: ctx.appId,
saleorApiUrl: ctx.saleorApiUrl, token: ctx.token,
requiredPermissions: meta?.requiredClientPermissions ?? [], saleorApiUrl: ctx.saleorApiUrl,
}); requiredPermissions: meta?.requiredClientPermissions ?? [],
} catch (e) { });
logger.debug("JWT verification failed, throwing"); } catch (e) {
throw new ProtectedHandlerError("JWT verification failed: ", "JWT_VERIFICATION_FAILED"); logger.debug("JWT verification failed, throwing");
} throw new ProtectedHandlerError("JWT verification failed: ", "JWT_VERIFICATION_FAILED");
} }
return next({ logger.debug("Token verified");
ctx: { return next();
...ctx,
saleorApiUrl: ctx.saleorApiUrl,
},
});
}); });
/** /**
@ -103,11 +99,12 @@ const validateClientToken = middleware(async ({ ctx, next, meta }) => {
* otherwise jwks validation will fail (if createCaller used) * otherwise jwks validation will fail (if createCaller used)
*/ */
export const protectedClientProcedure = procedure export const protectedClientProcedure = procedure
.use(attachLogger)
.use(attachAppToken) .use(attachAppToken)
.use(validateClientToken) .use(validateClientToken)
.use(async ({ ctx, next, path, type }) => { .use(async ({ ctx, next }) => {
const client = GraphqlClientFactory.fromAuthData({ const client = GraphqlClientFactory.fromAuthData({
token: ctx.appToken!, token: ctx.appToken,
saleorApiUrl: ctx.saleorApiUrl, saleorApiUrl: ctx.saleorApiUrl,
}); });
@ -125,12 +122,6 @@ export const protectedClientProcedure = procedure
return metadata ? AppConfig.parse(metadata) : new AppConfig(); return metadata ? AppConfig.parse(metadata) : new AppConfig();
}, },
logger: createLogger({
appId: ctx.appId,
apiUrl: ctx.saleorApiUrl,
type,
path,
}),
}, },
}); });
}); });