Webhooks optimization (#434)

* Disable webhooks in manifest and create graphql operations

* Add webhook enabler service

* Add webhooks to subscription events

* Disable webhooks when webhok operation fail

* display webhooks status

* Display failed deliveries

* Use react query in webhooks-status, refetch it when configu updated

* Extracted main instructons

* Better webhook instructions

* Optimize webhooks fetching from subscription

* Test for WebhooksAcivityToggler

* Changeset

* Test for webhook status handler WIP

* Test wip

* Rewrite test
This commit is contained in:
Lukasz Ostrowski 2023-04-29 11:30:48 +02:00 committed by GitHub
parent 3347a305cd
commit 40bed99316
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 1048 additions and 115 deletions

View file

@ -0,0 +1,13 @@
---
"saleor-app-search": minor
---
Added webhooks optimization feature. App will validate Algolia config in several places:
1. During config form submit
2. During loading frontend
3. During webhooks invocation
If Algolia "ping" fails with 403, app will disable webhooks, assuming its misconfigured.
Webhooks status is displayed in App configuration screen. If they are disabled, user can preview failed webhooks deliveries

View file

@ -0,0 +1,26 @@
fragment OwnWebhook on Webhook {
id
isActive
asyncEvents {
name
eventType
}
eventDeliveries(first: 100) {
edges {
node {
id
status
attempts(first: 100) {
edges {
node {
status
id
createdAt
responseStatusCode
}
}
}
}
}
}
}

View file

@ -0,0 +1,11 @@
mutation DisableWebhook($id: ID!) {
webhookUpdate(id: $id, input: { isActive: false }) {
errors {
message
}
webhook {
id
isActive
}
}
}

View file

@ -0,0 +1,11 @@
mutation EnableWebhook($id: ID!) {
webhookUpdate(id: $id, input: { isActive: true }) {
errors {
message
}
webhook {
id
isActive
}
}
}

View file

@ -0,0 +1,7 @@
query FetchOwnWebhooks($id: ID!) {
app(id: $id) {
webhooks {
...OwnWebhook
}
}
}

View file

@ -7,5 +7,10 @@ subscription ProductCreated {
...ProductWebhookPayload
}
}
recipient {
webhooks {
id
}
}
}
}

View file

@ -7,5 +7,10 @@ subscription ProductDeleted {
...ProductWebhookPayload
}
}
recipient {
webhooks {
id
}
}
}
}

View file

@ -7,5 +7,10 @@ subscription ProductUpdated {
...ProductWebhookPayload
}
}
recipient {
webhooks {
id
}
}
}
}

View file

@ -7,5 +7,10 @@ subscription ProductVariantCreated {
...ProductVariantWebhookPayload
}
}
recipient {
webhooks {
id
}
}
}
}

View file

@ -7,5 +7,10 @@ subscription ProductVariantDeleted {
...ProductVariantWebhookPayload
}
}
recipient {
webhooks {
id
}
}
}
}

View file

@ -7,5 +7,10 @@ subscription ProductVariantUpdated {
...ProductVariantWebhookPayload
}
}
recipient {
webhooks {
id
}
}
}
}

View file

@ -9,7 +9,8 @@
"lint": "next lint",
"lint:fix": "eslint --fix .",
"fetch-schema": "curl https://raw.githubusercontent.com/saleor/saleor/${npm_package_saleor_schemaVersion}/saleor/graphql/schema.graphql > graphql/schema.graphql",
"generate": "graphql-codegen"
"generate": "graphql-codegen",
"test": "vitest"
},
"saleor": {
"schemaVersion": "3.7"
@ -60,6 +61,7 @@
"prettier": "^2.7.1",
"typescript": "4.8.4",
"vite": "^4.3.1",
"vitest": "^0.30.1"
"vitest": "^0.30.1",
"node-mocks-http": "^1.12.2"
}
}

View file

@ -52,9 +52,14 @@ export const AlgoliaConfigurationForm = () => {
},
{
onSuccess: async () => {
reactQueryClient.refetchQueries({
queryKey: ["configuration"],
});
await Promise.all([
reactQueryClient.refetchQueries({
queryKey: ["configuration"],
}),
reactQueryClient.refetchQueries({
queryKey: ["webhooks-status"],
}),
]);
notifySuccess("Configuration saved!");
},
onError: async (data: Error) => {

View file

@ -3,10 +3,12 @@ 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 { useWebhooksStatus } from "../lib/useWebhooksStatus";
const BATCH_SIZE = 100;
export const ImportProductsToAlgolia = () => {
const [algoliaConfigured, setAlgoliaConfigured] = useState<null | boolean>(null);
const [started, setStarted] = useState(false);
const [currentProductIndex, setCurrentProductIndex] = useState(0);
const [isAlgoliaImporting, setIsAlgoliaImporting] = useState(false);
@ -34,6 +36,15 @@ export const ImportProductsToAlgolia = () => {
setStarted(true);
}, []);
useEffect(() => {
if (searchProvider) {
searchProvider
.ping()
.then(() => setAlgoliaConfigured(true))
.catch(() => setAlgoliaConfigured(false));
}
}, [searchProvider]);
useEffect(() => {
if (!searchProvider || isAlgoliaImporting || products.length <= currentProductIndex) {
return;
@ -53,7 +64,7 @@ export const ImportProductsToAlgolia = () => {
return (
<Box __cursor={started ? "wait" : "auto"}>
{searchProvider ? (
{searchProvider && algoliaConfigured ? (
<Box>
<Text variant={"heading"} as={"p"} marginBottom={4}>
Importing products & variants

View file

@ -0,0 +1,63 @@
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { Box, PropsWithBox, Text } from "@saleor/macaw-ui/next";
const SALEOR_EVENTS_DOCS_URL =
"https://docs.saleor.io/docs/3.x/developer/extending/apps/asynchronous-webhooks#available-webhook-events";
export const MainInstructions = ({ children, ...props }: PropsWithBox<{}>) => {
const { appBridge } = useAppBridge();
return (
<Box {...props}>
<Text as="p" marginBottom={4}>
To configure the App, fill in your Algolia settings to enable products indexing.
</Text>
<Text as="p" marginBottom={4}>
Once the App is configured, you will be able to perform initial index of your existing
Saleor database.
</Text>
<Text as="p">
The app supports following{" "}
<a
onClick={(e) => {
e.preventDefault();
/**
* TODO extract shared handler
*/
appBridge?.dispatch(
actions.Redirect({
to: SALEOR_EVENTS_DOCS_URL,
newContext: true,
})
);
}}
href={SALEOR_EVENTS_DOCS_URL}
>
events
</a>{" "}
that will synchronize Algolia in the background:
</Text>
<ul>
<li>
<code>- PRODUCT_CREATED</code>
</li>
<li>
<code>- PRODUCT_UPDATED</code>
</li>
<li>
<code>- PRODUCT_DELETED</code>
</li>
<li>
<code>- PRODUCT_VARIANT_CREATED</code>
</li>
<li>
<code>- PRODUCT_VARIANT_UPDATED</code>
</li>
<li>
<code>- PRODUCT_VARIANT_DELETED</code>
</li>
</ul>
</Box>
);
};

View file

@ -0,0 +1,104 @@
import { Accordion, Box, Chip, Text } from "@saleor/macaw-ui/next";
import { EventDeliveryStatusEnum } from "../../generated/graphql";
import { useWebhooksStatus } from "../lib/useWebhooksStatus";
export const WebhooksStatus = () => {
const { data: webhooksData } = useWebhooksStatus();
if (!webhooksData) {
return <Text>Loading...</Text>;
}
return (
<Box>
<Accordion display={"grid"} gap={4}>
{webhooksData.map((webhook) => {
const Trigger = webhook.isActive ? Box : Accordion.Item.Trigger;
const failedEventDeliveries = webhook.eventDeliveries?.edges?.filter(
(e) => e.node.status === EventDeliveryStatusEnum.Failed
);
const hasFailedDeliveries = failedEventDeliveries && failedEventDeliveries.length > 0;
return (
<Accordion.Item
value={webhook.id}
key={webhook.id}
borderBottomStyle={"solid"}
borderColor={"neutralPlain"}
borderBottomWidth={1}
>
<Trigger paddingBottom={4}>
<Box
width={"100%"}
display={"grid"}
gridTemplateColumns={2}
gap={4}
alignItems={"center"}
>
<Text size={"small"}>{webhook.asyncEvents[0].name}</Text>
<Chip
padding={2}
marginLeft={"auto"}
size={"small"}
backgroundColor={
webhook.isActive ? "decorativeSurfaceSubdued2" : "surfaceCriticalSubdued"
}
>
<Text
color={webhook.isActive ? "text2Decorative" : "textCriticalSubdued"}
textTransform={"uppercase"}
margin={3}
variant={"caption"}
>
{webhook.isActive ? "Active" : "Disabled"}
</Text>
</Chip>
</Box>
</Trigger>
<Accordion.Item.Content>
<Box marginY={6}>
<Text variant={"bodyStrong"}>Delivery attempts</Text>
{!hasFailedDeliveries ? (
<Box>
<Text size={"small"}>No failed deliveries</Text>
</Box>
) : null}
<Box>
{webhook.eventDeliveries?.edges.map((delivery) => (
<Box key={delivery.node.id}>
{delivery.node.attempts?.edges.map((attempt) => (
<Box
display={"grid"}
gridTemplateColumns={3}
gap={3}
key={attempt.node.id}
>
<Text display={"block"} size={"small"}>
<Text color={"textNeutralSubdued"}>Status</Text>:{" "}
<Text color={"textCriticalSubdued"}>{attempt.node.status}</Text>
</Text>
<Text display={"block"} size={"small"}>
<Text color={"textNeutralSubdued"}>HTTP </Text>
<Text color={"textCriticalSubdued"}>
{attempt.node.responseStatusCode}
</Text>
</Text>
<Text display={"block"} size={"small"} marginLeft={"auto"}>
{new Date(attempt.node.createdAt).toLocaleString()}
</Text>
</Box>
))}
</Box>
))}
</Box>
</Box>
</Accordion.Item.Content>
</Accordion.Item>
);
})}
</Accordion>
</Box>
);
};

View file

@ -0,0 +1,22 @@
import { Text } from "@saleor/macaw-ui/next";
import { useWebhooksStatus } from "../lib/useWebhooksStatus";
export const WebhooksStatusInstructions = () => {
const { data: webhooksData } = useWebhooksStatus();
if (webhooksData && webhooksData.some((w) => !w.isActive)) {
return (
<>
<Text as={"p"} marginBottom={4}>
Check status of registered webhooks.
</Text>
<Text as={"p"} color={"iconCriticalDefault"}>
Your webhooks were disabled. Most likely, your configuration is invalid. Please check your
credentials
</Text>
</>
);
}
return <Text>Check status of registered webhooks.</Text>;
};

View file

@ -0,0 +1,65 @@
import { Client } from "urql";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { IWebhooksActivityClient, WebhookActivityTogglerService } from "./WebhookActivityToggler.service";
describe("WebhookActivityTogglerService", function () {
let mockWebhooksClient: IWebhooksActivityClient;
let service: WebhookActivityTogglerService;
beforeEach(() => {
mockWebhooksClient = {
enableSingleWebhook: vi.fn(),
disableSingleWebhook: vi.fn(),
fetchAppWebhooksIDs: vi.fn(),
};
service = new WebhookActivityTogglerService("ID", {} as Client, {
WebhooksClient: mockWebhooksClient,
});
});
describe("disableOwnWebhooks", () => {
it("Calls disable single webhook procedures for each webhook in argument", async () => {
await service.disableOwnWebhooks(["w1", "w2", "w3"]);
expect(mockWebhooksClient.fetchAppWebhooksIDs).not.toHaveBeenCalled();
expect(mockWebhooksClient.disableSingleWebhook).toHaveBeenCalledTimes(3);
expect(mockWebhooksClient.disableSingleWebhook).toHaveBeenNthCalledWith(1, "w1");
expect(mockWebhooksClient.disableSingleWebhook).toHaveBeenNthCalledWith(2, "w2");
expect(mockWebhooksClient.disableSingleWebhook).toHaveBeenNthCalledWith(3, "w3");
});
it("Calls disable single webhook procedures for each webhook fetched from API client", async () => {
vi.mocked(mockWebhooksClient.fetchAppWebhooksIDs).mockImplementationOnce(async () => [
"w1",
"w2",
"w3",
]);
await service.disableOwnWebhooks();
expect(mockWebhooksClient.fetchAppWebhooksIDs).toHaveBeenCalled();
expect(mockWebhooksClient.disableSingleWebhook).toHaveBeenCalledTimes(3);
expect(mockWebhooksClient.disableSingleWebhook).toHaveBeenNthCalledWith(1, "w1");
expect(mockWebhooksClient.disableSingleWebhook).toHaveBeenNthCalledWith(2, "w2");
expect(mockWebhooksClient.disableSingleWebhook).toHaveBeenNthCalledWith(3, "w3");
});
});
describe("enableOwnWebhooks", () => {
it("Calls enable single webhooks procedures for each webhook fetched from API client", async () => {
vi.mocked(mockWebhooksClient.fetchAppWebhooksIDs).mockImplementationOnce(async () => [
"w1",
"w2",
"w3",
]);
await service.enableOwnWebhooks();
expect(mockWebhooksClient.fetchAppWebhooksIDs).toHaveBeenCalled();
expect(mockWebhooksClient.enableSingleWebhook).toHaveBeenCalledTimes(3);
expect(mockWebhooksClient.enableSingleWebhook).toHaveBeenNthCalledWith(1, "w1");
expect(mockWebhooksClient.enableSingleWebhook).toHaveBeenNthCalledWith(2, "w2");
expect(mockWebhooksClient.enableSingleWebhook).toHaveBeenNthCalledWith(3, "w3");
});
});
});

View file

@ -0,0 +1,124 @@
import { Client, OperationResult } from "urql";
import {
DisableWebhookDocument,
EnableWebhookDocument,
FetchOwnWebhooksDocument,
} from "../../generated/graphql";
import { createLogger } from "../lib/logger";
const logger = createLogger({
service: "WebhookActivityTogglerService",
});
export interface IWebhooksActivityClient {
fetchAppWebhooksIDs(id: string): Promise<string[]>;
disableSingleWebhook(id: string): Promise<void>;
enableSingleWebhook(id: string): Promise<void>;
}
export interface IWebhookActivityTogglerService {
disableOwnWebhooks(webhooksIdsParam?: string[]): Promise<void>;
enableOwnWebhooks(): Promise<void>;
}
export class WebhooksActivityClient implements IWebhooksActivityClient {
constructor(private client: Pick<Client, "query" | "mutation">) {}
private handleOperationFailure(r: OperationResult) {
if (r.error || !r.data) {
logger.error(
{
error: r.error,
},
"Error disabling webhook"
);
throw new Error("Error disabling webhook");
}
}
fetchAppWebhooksIDs(id: string) {
return this.client
.query(FetchOwnWebhooksDocument, { id })
.toPromise()
.then((r) => {
this.handleOperationFailure(r);
if (!r.data?.app?.webhooks) {
throw new Error("Webhooks not registered for app, something is wrong");
}
return r.data?.app?.webhooks?.map((w) => w.id);
});
}
disableSingleWebhook(id: string): Promise<void> {
return this.client
.mutation(DisableWebhookDocument, {
id,
})
.toPromise()
.then((r) => {
this.handleOperationFailure(r);
return undefined;
});
}
enableSingleWebhook(id: string): Promise<void> {
return this.client
.mutation(EnableWebhookDocument, {
id,
})
.toPromise()
.then((r) => {
this.handleOperationFailure(r);
return undefined;
});
}
}
export class WebhookActivityTogglerService implements IWebhookActivityTogglerService {
/**
* Extracted separate client for easier testing without touching graphQL
*/
private webhooksClient: IWebhooksActivityClient;
constructor(
private ownAppId: string,
private client: Pick<Client, "query" | "mutation">,
options?: {
WebhooksClient: IWebhooksActivityClient;
}
) {
this.webhooksClient = options?.WebhooksClient ?? new WebhooksActivityClient(this.client);
}
/**
* Disable webhooks with provided IDs. If not provided, it will fetch them from Saleor
*/
async disableOwnWebhooks(webhooksIdsParam?: string[]) {
const webhooksIds =
webhooksIdsParam ?? (await this.webhooksClient.fetchAppWebhooksIDs(this.ownAppId));
logger.info(webhooksIds, "Disabling own webhooks");
if (!webhooksIds) {
throw new Error("Failed fetching webhooks");
}
await Promise.all(webhooksIds.map((id) => this.webhooksClient.disableSingleWebhook(id)));
}
async enableOwnWebhooks() {
const webhooksIds = await this.webhooksClient.fetchAppWebhooksIDs(this.ownAppId);
logger.info(webhooksIds, "Enabling own webhooks");
if (!webhooksIds) {
throw new Error("Failed fetching webhooks");
}
await Promise.all(webhooksIds.map((id) => this.webhooksClient.enableSingleWebhook(id)));
}
}

View file

@ -138,7 +138,14 @@ export class AlgoliaSearchProvider implements SearchProvider {
}
async ping() {
return this.#algolia.listIndices().then(() => undefined);
return this.#algolia
.listIndices()
.then(() => undefined)
.catch((r) => {
if (r.status === 403) {
throw new Error("Algolia responded with invalid credentials");
}
});
}
}

View file

@ -1,10 +1,10 @@
import { initUrqlClient } from "next-urql";
import { AuthConfig, authExchange } from "@urql/exchange-auth";
import {
cacheExchange,
cacheExchange, Client,
createClient as urqlCreateClient,
dedupExchange,
fetchExchange,
fetchExchange
} from "urql";
interface IAuthState {
@ -59,3 +59,5 @@ export const createClient = (url: string, getAuth: AuthConfig<IAuthState>["getAu
url,
exchanges: getExchanges(getAuth),
});
export type SimpleGraphqlClient = Pick<Client, "query" | "mutation">;

View file

@ -1,12 +1,9 @@
import { EncryptedMetadataManager, MetadataEntry } from "@saleor/app-sdk/settings-manager";
import { Client } from "urql";
import { EncryptedMetadataManager, MetadataEntry, SettingsManager } from "@saleor/app-sdk/settings-manager";
import {
FetchAppDetailsDocument,
FetchAppDetailsQuery,
UpdateAppMetadataDocument,
} from "../../generated/graphql";
import { FetchAppDetailsDocument, FetchAppDetailsQuery, UpdateAppMetadataDocument } from "../../generated/graphql";
import { settingsManagerSecretKey } from "../../saleor-app";
import { SimpleGraphqlClient } from "./graphql";
/*
* Function is using urql graphql client to fetch all available metadata.
@ -14,7 +11,7 @@ import { settingsManagerSecretKey } from "../../saleor-app";
* which can be used by the manager.
* Result of this query is cached by the manager.
*/
export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]> {
export async function fetchAllMetadata(client: SimpleGraphqlClient): Promise<MetadataEntry[]> {
const { error, data } = await client
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
.toPromise();
@ -32,7 +29,7 @@ export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]>
* 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[]) {
export async function mutateMetadata(client: SimpleGraphqlClient, metadata: MetadataEntry[]) {
// to update the metadata, ID is required
const { error: idQueryError, data: idQueryData } = await client
.query(FetchAppDetailsDocument, {})
@ -72,7 +69,7 @@ export async function mutateMetadata(client: Client, metadata: MetadataEntry[])
);
}
export const createSettingsManager = (client: Client) => {
export const createSettingsManager = (client: SimpleGraphqlClient): SettingsManager => {
/*
* EncryptedMetadataManager gives you interface to manipulate metadata and cache values in memory.
* We recommend it for production, because all values are encrypted.

View file

@ -10,4 +10,5 @@ export interface SearchProvider {
createProductVariant(productVariant: ProductVariantWebhookPayloadFragment): Promise<void>;
updateProductVariant(productVariant: ProductVariantWebhookPayloadFragment): Promise<void>;
deleteProductVariant(productId: ProductVariantWebhookPayloadFragment): Promise<void>;
ping(): Promise<void>;
}

View file

@ -0,0 +1,20 @@
import { useQuery } from "react-query";
import { useAuthenticatedFetch } from "@saleor/app-sdk/app-bridge";
import { useCallback } from "react";
import { OwnWebhookFragment } from "../../generated/graphql";
export const useWebhooksStatus = () => {
const fetch: typeof window.fetch = useAuthenticatedFetch();
const fetchFn = useCallback(() => {
return fetch("/api/webhooks-status").then((resp) => resp.json());
/**
* fetch from SDK is not wrapped with memo todo
*/
}, []);
return useQuery<OwnWebhookFragment[]>({
queryKey: "webhooks-status",
queryFn: fetchFn,
});
};

View file

@ -8,6 +8,8 @@ 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";
const logger = createLogger({
handler: "api/configuration",
@ -65,12 +67,37 @@ export const handler = async (
const { appId, secretKey, indexNamePrefix } = JSON.parse(req.body) as AppConfigurationFields;
await settings.set([
{ key: "secretKey", value: secretKey || "", domain },
{ key: "appId", value: appId || "", domain },
{ key: "indexNamePrefix", value: indexNamePrefix || "", domain },
]);
const algoliaClient = new AlgoliaSearchProvider({
appId,
apiKey: secretKey,
indexNamePrefix: indexNamePrefix,
});
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");

View file

@ -0,0 +1,123 @@
import { createProtectedHandler, NextProtectedApiHandler } from "@saleor/app-sdk/handlers/next";
import { saleorApp } from "../../../saleor-app";
import { createClient, SimpleGraphqlClient } from "../../lib/graphql";
import { FetchOwnWebhooksDocument } from "../../../generated/graphql";
import { AlgoliaSearchProvider } from "../../lib/algolia/algoliaSearchProvider";
import { createSettingsManager } from "../../lib/metadata";
import {
IWebhookActivityTogglerService,
WebhookActivityTogglerService,
} from "../../domain/WebhookActivityToggler.service";
import { createLogger } from "../../lib/logger";
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
import { Client } from "urql";
import { SearchProvider } from "../../lib/searchProvider";
const logger = createLogger({
service: "webhooksStatusHandler",
});
/**
* Simple dependency injection - factory injects all services, in tests everything can be configured without mocks
*/
type FactoryProps = {
settingsManagerFactory: (client: SimpleGraphqlClient) => SettingsManager;
webhookActivityTogglerFactory: (
appId: string,
client: SimpleGraphqlClient
) => IWebhookActivityTogglerService;
algoliaSearchProviderFactory: (appId: string, apiKey: string) => Pick<SearchProvider, "ping">;
graphqlClientFactory: (saleorApiUrl: string, token: string) => SimpleGraphqlClient;
};
export const webhooksStatusHandlerFactory =
({
settingsManagerFactory,
webhookActivityTogglerFactory,
algoliaSearchProviderFactory,
graphqlClientFactory,
}: FactoryProps): NextProtectedApiHandler =>
async (req, res, { authData }) => {
/**
* Initialize services
*/
const client = graphqlClientFactory(authData.saleorApiUrl, authData.token);
const webhooksToggler = webhookActivityTogglerFactory(authData.appId, client);
const settingsManager = settingsManagerFactory(client);
const domain = new URL(authData.saleorApiUrl).host;
const [secretKey, appId] = await Promise.all([
settingsManager.get("secretKey", domain),
settingsManager.get("appId", domain),
]);
const settings = { secretKey, appId };
logger.debug(settings, "fetched settings");
/**
* If settings are incomplete, disable webhooks
*
* TODO Extract config operations to domain/
*/
if (!settings.appId || !settings.secretKey) {
logger.debug("Settings not set, will disable webhooks");
await webhooksToggler.disableOwnWebhooks();
} else {
/**
* Otherwise, if settings are set, check in Algolia if tokens are valid
*/
const algoliaService = algoliaSearchProviderFactory(settings.appId, settings.secretKey);
try {
logger.debug("Settings set, will ping Algolia");
await algoliaService.ping();
} catch (e) {
logger.debug("Algolia ping failed, will disable webhooks");
/**
* If credentials are invalid, also disable webhooks
*/
await webhooksToggler.disableOwnWebhooks();
}
}
try {
logger.debug("Settings and Algolia are correct, will fetch Webhooks from Saleor");
const webhooks = await client
.query(FetchOwnWebhooksDocument, { id: authData.appId })
.toPromise()
.then((r) => r.data?.app?.webhooks);
if (!webhooks) {
return res.status(500).end();
}
return res.status(200).json(webhooks);
} catch (e) {
console.error(e);
return res.status(500).end();
}
};
export default createProtectedHandler(
webhooksStatusHandlerFactory({
settingsManagerFactory: createSettingsManager,
webhookActivityTogglerFactory: function (appId, client) {
return new WebhookActivityTogglerService(appId, client);
},
algoliaSearchProviderFactory(appId, apiKey) {
return new AlgoliaSearchProvider({ appId, apiKey });
},
graphqlClientFactory(saleorApiUrl: string, token: string) {
return createClient(saleorApiUrl, async () => ({
token,
}));
},
}),
saleorApp.apl,
[]
);

View file

@ -3,7 +3,9 @@ import { ProductCreated, ProductCreatedDocument } from "../../../../../generated
import { saleorApp } from "../../../../../saleor-app";
import { AlgoliaSearchProvider } from "../../../../lib/algolia/algoliaSearchProvider";
import { getAlgoliaConfiguration } from "../../../../lib/algolia/getAlgoliaConfiguration";
import { createDebug } from "../../../../lib/debug";
import { WebhookActivityTogglerService } from "../../../../domain/WebhookActivityToggler.service";
import { createClient } from "../../../../lib/graphql";
import { createLogger } from "../../../../lib/logger";
export const config = {
api: {
@ -16,22 +18,29 @@ export const webhookProductCreated = new SaleorAsyncWebhook<ProductCreated>({
event: "PRODUCT_CREATED",
apl: saleorApp.apl,
query: ProductCreatedDocument,
/**
* Webhook is disabled by default. Will be enabled by the app when configuration succeeds
*/
isActive: false,
});
const logger = createLogger({
service: "webhookProductCreatedWebhookHandler",
});
export const handler: NextWebhookApiHandler<ProductCreated> = async (req, res, context) => {
const debug = createDebug(`Webhook handler - ${webhookProductCreated.event}`);
const { event, authData } = context;
debug(
logger.debug(
`New event ${event} (${context.payload?.__typename}) from the ${authData.domain} domain has been received!`
);
const { settings, errors } = await getAlgoliaConfiguration({ authData });
if (errors?.length || !settings) {
debug("Aborting due to lack of settings");
debug(errors);
logger.warn("Aborting due to lack of settings");
logger.debug(errors);
return res.status(400).json({
message: errors[0].message,
});
@ -46,7 +55,26 @@ export const handler: NextWebhookApiHandler<ProductCreated> = async (req, res, c
const { product } = context.payload;
if (product) {
await searchProvider.createProduct(product);
try {
await searchProvider.createProduct(product);
} catch (e) {
logger.info(e, "Algolia createProduct failed. Webhooks will be disabled");
const webhooksToggler = new WebhookActivityTogglerService(
authData.appId,
createClient(authData.saleorApiUrl, async () => ({ token: authData.token }))
);
logger.trace("Will disable webhooks");
await webhooksToggler.disableOwnWebhooks(
context.payload.recipient?.webhooks?.map((w) => w.id)
);
logger.trace("Webhooks disabling operation finished");
return res.status(500).send("Operation failed, webhooks are disabled");
}
}
res.status(200).end();
return;

View file

@ -4,6 +4,9 @@ import { saleorApp } from "../../../../../saleor-app";
import { AlgoliaSearchProvider } from "../../../../lib/algolia/algoliaSearchProvider";
import { getAlgoliaConfiguration } from "../../../../lib/algolia/getAlgoliaConfiguration";
import { createDebug } from "../../../../lib/debug";
import { WebhookActivityTogglerService } from "../../../../domain/WebhookActivityToggler.service";
import { createClient } from "../../../../lib/graphql";
import { createLogger } from "../../../../lib/logger";
export const config = {
api: {
@ -16,22 +19,29 @@ export const webhookProductDeleted = new SaleorAsyncWebhook<ProductDeleted>({
event: "PRODUCT_DELETED",
apl: saleorApp.apl,
query: ProductDeletedDocument,
/**
* Webhook is disabled by default. Will be enabled by the app when configuration succeeds
*/
isActive: false,
});
const logger = createLogger({
service: "webhookProductDeletedWebhookHandler",
});
export const handler: NextWebhookApiHandler<ProductDeleted> = async (req, res, context) => {
const debug = createDebug(`Webhook handler - ${webhookProductDeleted.event}`);
const { event, authData } = context;
debug(
logger.debug(
`New event ${event} (${context.payload?.__typename}) from the ${authData.domain} domain has been received!`
);
const { settings, errors } = await getAlgoliaConfiguration({ authData });
if (errors?.length || !settings) {
debug("Aborting due to lack of settings");
debug(errors);
logger.warn("Aborting due to lack of settings");
logger.debug(errors);
return res.status(400).json({
message: errors[0].message,
});
@ -46,7 +56,26 @@ export const handler: NextWebhookApiHandler<ProductDeleted> = async (req, res, c
const { product } = context.payload;
if (product) {
await searchProvider.deleteProduct(product);
try {
await searchProvider.deleteProduct(product);
} catch (e) {
logger.info(e, "Algolia deleteProduct failed. Webhooks will be disabled");
const webhooksToggler = new WebhookActivityTogglerService(
authData.appId,
createClient(authData.saleorApiUrl, async () => ({ token: authData.token }))
);
logger.trace("Will disable webhooks");
await webhooksToggler.disableOwnWebhooks(
context.payload.recipient?.webhooks?.map((w) => w.id)
);
logger.trace("Webhooks disabling operation finished");
return res.status(500).send("Operation failed, webhooks are disabled");
}
}
res.status(200).end();
return;

View file

@ -4,6 +4,9 @@ import { saleorApp } from "../../../../../saleor-app";
import { AlgoliaSearchProvider } from "../../../../lib/algolia/algoliaSearchProvider";
import { getAlgoliaConfiguration } from "../../../../lib/algolia/getAlgoliaConfiguration";
import { createDebug } from "../../../../lib/debug";
import { WebhookActivityTogglerService } from "../../../../domain/WebhookActivityToggler.service";
import { createClient } from "../../../../lib/graphql";
import { createLogger } from "../../../../lib/logger";
export const config = {
api: {
@ -16,22 +19,29 @@ export const webhookProductUpdated = new SaleorAsyncWebhook<ProductUpdated>({
event: "PRODUCT_UPDATED",
apl: saleorApp.apl,
query: ProductUpdatedDocument,
/**
* Webhook is disabled by default. Will be enabled by the app when configuration succeeds
*/
isActive: false,
});
const logger = createLogger({
service: "webhookProductUpdatedWebhookHandler",
});
export const handler: NextWebhookApiHandler<ProductUpdated> = async (req, res, context) => {
const debug = createDebug(`Webhook handler - ${webhookProductUpdated.event}`);
const { event, authData } = context;
debug(
logger.debug(
`New event ${event} (${context.payload?.__typename}) from the ${authData.domain} domain has been received!`
);
const { settings, errors } = await getAlgoliaConfiguration({ authData });
if (errors?.length || !settings) {
debug("Aborting due to lack of settings");
debug(errors);
logger.warn("Aborting due to lack of settings");
logger.debug(errors);
return res.status(400).json({
message: errors[0].message,
});
@ -46,8 +56,28 @@ export const handler: NextWebhookApiHandler<ProductUpdated> = async (req, res, c
const { product } = context.payload;
if (product) {
await searchProvider.updateProduct(product);
try {
await searchProvider.updateProduct(product);
} catch (e) {
logger.info(e, "Algolia updateProduct failed. Webhooks will be disabled");
const webhooksToggler = new WebhookActivityTogglerService(
authData.appId,
createClient(authData.saleorApiUrl, async () => ({ token: authData.token }))
);
logger.trace("Will disable webhooks");
await webhooksToggler.disableOwnWebhooks(
context.payload.recipient?.webhooks?.map((w) => w.id)
);
logger.trace("Webhooks disabling operation finished");
return res.status(500).send("Operation failed, webhooks are disabled");
}
}
res.status(200).end();
return;
};

View file

@ -7,6 +7,9 @@ import { saleorApp } from "../../../../../saleor-app";
import { AlgoliaSearchProvider } from "../../../../lib/algolia/algoliaSearchProvider";
import { getAlgoliaConfiguration } from "../../../../lib/algolia/getAlgoliaConfiguration";
import { createDebug } from "../../../../lib/debug";
import { createLogger } from "../../../../lib/logger";
import { WebhookActivityTogglerService } from "../../../../domain/WebhookActivityToggler.service";
import { createClient } from "../../../../lib/graphql";
export const config = {
api: {
@ -19,22 +22,28 @@ export const webhookProductVariantCreated = new SaleorAsyncWebhook<ProductVarian
event: "PRODUCT_VARIANT_CREATED",
apl: saleorApp.apl,
query: ProductVariantCreatedDocument,
/**
* Webhook is disabled by default. Will be enabled by the app when configuration succeeds
*/
isActive: false,
});
const logger = createLogger({
service: "webhookProductVariantCreatedWebhookHandler",
});
export const handler: NextWebhookApiHandler<ProductVariantCreated> = async (req, res, context) => {
const debug = createDebug(`Webhook handler - ${webhookProductVariantCreated.event}`);
const { event, authData } = context;
debug(
logger.debug(
`New event ${event} (${context.payload?.__typename}) from the ${authData.domain} domain has been received!`
);
const { settings, errors } = await getAlgoliaConfiguration({ authData });
if (errors?.length || !settings) {
debug("Aborting due to lack of settings");
debug(errors);
logger.warn("Aborting due to lack of settings");
logger.debug(errors);
return res.status(400).json({
message: errors[0].message,
});
@ -49,8 +58,28 @@ export const handler: NextWebhookApiHandler<ProductVariantCreated> = async (req,
const { productVariant } = context.payload;
if (productVariant) {
await searchProvider.createProductVariant(productVariant);
try {
await searchProvider.createProductVariant(productVariant);
} catch (e) {
logger.info(e, "Algolia createProductVariant failed. Webhooks will be disabled");
const webhooksToggler = new WebhookActivityTogglerService(
authData.appId,
createClient(authData.saleorApiUrl, async () => ({ token: authData.token }))
);
logger.trace("Will disable webhooks");
await webhooksToggler.disableOwnWebhooks(
context.payload.recipient?.webhooks?.map((w) => w.id)
);
logger.trace("Webhooks disabling operation finished");
return res.status(500).send("Operation failed, webhooks are disabled");
}
}
res.status(200).end();
return;
};

View file

@ -7,6 +7,9 @@ import { saleorApp } from "../../../../../saleor-app";
import { AlgoliaSearchProvider } from "../../../../lib/algolia/algoliaSearchProvider";
import { getAlgoliaConfiguration } from "../../../../lib/algolia/getAlgoliaConfiguration";
import { createDebug } from "../../../../lib/debug";
import { createLogger } from "../../../../lib/logger";
import { WebhookActivityTogglerService } from "../../../../domain/WebhookActivityToggler.service";
import { createClient } from "../../../../lib/graphql";
export const config = {
api: {
@ -19,22 +22,29 @@ export const webhookProductVariantDeleted = new SaleorAsyncWebhook<ProductVarian
event: "PRODUCT_VARIANT_DELETED",
apl: saleorApp.apl,
query: ProductVariantDeletedDocument,
/**
* Webhook is disabled by default. Will be enabled by the app when configuration succeeds
*/
isActive: false,
});
const logger = createLogger({
service: "webhookProductVariantDeletedWebhookHandler",
});
export const handler: NextWebhookApiHandler<ProductVariantDeleted> = async (req, res, context) => {
const debug = createDebug(`Webhook handler - ${webhookProductVariantDeleted.event}`);
const { event, authData } = context;
debug(
logger.debug(
`New event ${event} (${context.payload?.__typename}) from the ${authData.domain} domain has been received!`
);
const { settings, errors } = await getAlgoliaConfiguration({ authData });
if (errors?.length || !settings) {
debug("Aborting due to lack of settings");
debug(errors);
logger.warn("Aborting due to lack of settings");
logger.debug(errors);
return res.status(400).json({
message: errors[0].message,
});
@ -49,8 +59,28 @@ export const handler: NextWebhookApiHandler<ProductVariantDeleted> = async (req,
const { productVariant } = context.payload;
if (productVariant) {
await searchProvider.deleteProductVariant(productVariant);
try {
await searchProvider.deleteProductVariant(productVariant);
} catch (e) {
logger.info(e, "Algolia deleteProductVariant failed. Webhooks will be disabled");
const webhooksToggler = new WebhookActivityTogglerService(
authData.appId,
createClient(authData.saleorApiUrl, async () => ({ token: authData.token }))
);
logger.trace("Will disable webhooks");
await webhooksToggler.disableOwnWebhooks(
context.payload.recipient?.webhooks?.map((w) => w.id)
);
logger.trace("Webhooks disabling operation finished");
return res.status(500).send("Operation failed, webhooks are disabled");
}
}
res.status(200).end();
return;
};

View file

@ -7,6 +7,9 @@ import { saleorApp } from "../../../../../saleor-app";
import { AlgoliaSearchProvider } from "../../../../lib/algolia/algoliaSearchProvider";
import { getAlgoliaConfiguration } from "../../../../lib/algolia/getAlgoliaConfiguration";
import { createDebug } from "../../../../lib/debug";
import { createLogger } from "../../../../lib/logger";
import { WebhookActivityTogglerService } from "../../../../domain/WebhookActivityToggler.service";
import { createClient } from "../../../../lib/graphql";
export const config = {
api: {
@ -19,22 +22,29 @@ export const webhookProductVariantUpdated = new SaleorAsyncWebhook<ProductVarian
event: "PRODUCT_VARIANT_UPDATED",
apl: saleorApp.apl,
query: ProductVariantUpdatedDocument,
/**
* Webhook is disabled by default. Will be enabled by the app when configuration succeeds
*/
isActive: false,
});
const logger = createLogger({
service: "webhookProductVariantUpdatedWebhookHandler",
});
export const handler: NextWebhookApiHandler<ProductVariantUpdated> = async (req, res, context) => {
const debug = createDebug(`Webhook handler - ${webhookProductVariantUpdated.event}`);
const { event, authData } = context;
debug(
logger.debug(
`New event ${event} (${context.payload?.__typename}) from the ${authData.domain} domain has been received!`
);
const { settings, errors } = await getAlgoliaConfiguration({ authData });
if (errors?.length || !settings) {
debug("Aborting due to lack of settings");
debug(errors);
logger.warn("Aborting due to lack of settings");
logger.debug(errors);
return res.status(400).json({
message: errors[0].message,
});
@ -49,8 +59,28 @@ export const handler: NextWebhookApiHandler<ProductVariantUpdated> = async (req,
const { productVariant } = context.payload;
if (productVariant) {
await searchProvider.updateProductVariant(productVariant);
try {
await searchProvider.updateProductVariant(productVariant);
} catch (e) {
logger.info(e, "Algolia updateProductVariant failed. Webhooks will be disabled");
const webhooksToggler = new WebhookActivityTogglerService(
authData.appId,
createClient(authData.saleorApiUrl, async () => ({ token: authData.token }))
);
logger.trace("Will disable webhooks");
await webhooksToggler.disableOwnWebhooks(
context.payload.recipient?.webhooks?.map((w) => w.id)
);
logger.trace("Webhooks disabling operation finished");
return res.status(500).send("Operation failed, webhooks are disabled");
}
}
res.status(200).end();
return;
};

View file

@ -0,0 +1,125 @@
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
import { createMocks } from "node-mocks-http";
import { webhooksStatusHandlerFactory } from "../../pages/api/webhooks-status";
import { Client, OperationResult } from "urql";
import { IWebhookActivityTogglerService } from "../../domain/WebhookActivityToggler.service";
import { SearchProvider } from "../../lib/searchProvider";
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
import { NextProtectedApiHandler } from "@saleor/app-sdk/handlers/next";
import {
FetchOwnWebhooksQuery,
WebhookEventTypeAsyncEnum,
WebhookEventTypeEnum,
} from "../../../generated/graphql";
/**
* Context provided from ProtectedApiHandler to handler body
*/
const mockWebhookContext = {
authData: {
appId: "app-id",
domain: "domain.saleor.io",
token: "token",
saleorApiUrl: "https://domain.saleor.io/graphql",
},
baseUrl: "localhost:3000",
};
const appWebhooksResponseData: Pick<OperationResult<FetchOwnWebhooksQuery, any>, "data"> = {
data: {
app: {
webhooks: [
{
id: "w1",
isActive: true,
asyncEvents: [
{ eventType: WebhookEventTypeAsyncEnum.ProductCreated, name: "ProductCreated" },
],
eventDeliveries: {
edges: [],
},
},
],
},
},
};
describe("webhooksStatusHandler", () => {
const client: Pick<Client, "query" | "mutation"> = {
query: vi.fn(),
mutation: vi.fn(),
};
const webhooksTogglerServiceMock: IWebhookActivityTogglerService = {
disableOwnWebhooks: vi.fn(),
enableOwnWebhooks: vi.fn(),
};
const algoliaSearchProviderMock: Pick<SearchProvider, "ping"> = {
ping: vi.fn(),
};
const settingsManagerMock: SettingsManager = {
get: vi.fn(),
set: vi.fn(),
};
let handler: NextProtectedApiHandler;
beforeEach(() => {
vi.resetAllMocks();
handler = webhooksStatusHandlerFactory({
graphqlClientFactory: () => client,
webhookActivityTogglerFactory: () => webhooksTogglerServiceMock,
algoliaSearchProviderFactory: () => algoliaSearchProviderMock,
settingsManagerFactory: () => settingsManagerMock,
});
(client.query as Mock).mockImplementationOnce(() => {
return {
async toPromise() {
return appWebhooksResponseData;
},
};
});
});
it("Disables webhooks if Algolia settings are not saved in Saleor Metadata", async function () {
const { req, res } = createMocks({});
await handler(req, res, mockWebhookContext);
expect(webhooksTogglerServiceMock.disableOwnWebhooks).toHaveBeenCalled();
expect(algoliaSearchProviderMock.ping).not.toHaveBeenCalled();
expect(res._getStatusCode()).toBe(200);
});
it("Disables webhooks if Algolia credentials are invalid", async function () {
(settingsManagerMock.get as Mock).mockReturnValue("metadata-value");
(algoliaSearchProviderMock.ping as Mock).mockImplementationOnce(async () => {
throw new Error();
});
const { req, res } = createMocks({});
await handler(req, res, mockWebhookContext);
expect(webhooksTogglerServiceMock.disableOwnWebhooks).toHaveBeenCalled();
expect(algoliaSearchProviderMock.ping).toHaveBeenCalled();
expect(res._getStatusCode()).toBe(200);
});
it("Returns webhooks if Algolia credentials are valid", async function () {
(settingsManagerMock.get as Mock).mockReturnValue("metadata-value");
(algoliaSearchProviderMock.ping as Mock).mockImplementationOnce(async () => Promise.resolve());
const { req, res } = createMocks({});
await handler(req, res, mockWebhookContext);
expect(webhooksTogglerServiceMock.disableOwnWebhooks).not.toHaveBeenCalled();
expect(algoliaSearchProviderMock.ping).toHaveBeenCalled();
expect(res._getStatusCode()).toBe(200);
});
});

View file

@ -3,9 +3,9 @@ import { AppSection } from "../../components/AppSection";
import { AlgoliaConfigurationForm } from "../../components/AlgoliaConfigurationForm";
import { ImportProductsToAlgolia } from "../../components/ImportProductsToAlgolia";
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
const SALEOR_EVENTS_DOCS_URL =
"https://docs.saleor.io/docs/3.x/developer/extending/apps/asynchronous-webhooks#available-webhook-events";
import { WebhooksStatus } from "../../components/WebhooksStatus";
import { MainInstructions } from "../../components/MainInstructions";
import { WebhooksStatusInstructions } from "../../components/WebhooksStatusInstructions";
const ALGOLIA_DASHBOARD_TOKENS_URL = "https://www.algolia.com/account/api-keys/all";
@ -17,59 +17,17 @@ export const ConfigurationView = () => {
<Text variant={"hero"} size={"medium"} as={"h1"}>
Configuration
</Text>
<Box marginTop={4} __marginBottom={"100px"}>
<Text as="p" marginBottom={4}>
To configure the App, fill in your Algolia settings to enable products indexing.
</Text>
<Text as="p" marginBottom={4}>
Once the App is configured, you will be able to perform initial index of your existing
Saleor database.
</Text>
<Text as="p">
The app supports following{" "}
<a
onClick={(e) => {
e.preventDefault();
/**
* TODO extract shared handler
*/
appBridge?.dispatch(
actions.Redirect({
to: SALEOR_EVENTS_DOCS_URL,
newContext: true,
})
);
}}
href={SALEOR_EVENTS_DOCS_URL}
>
events
</a>{" "}
that will synchronize Algolia in the background:
</Text>
<ul>
<li>
<code>- PRODUCT_CREATED</code>
</li>
<li>
<code>- PRODUCT_UPDATED</code>
</li>
<li>
<code>- PRODUCT_DELETED</code>
</li>
<li>
<code>- PRODUCT_VARIANT_CREATED</code>
</li>
<li>
<code>- PRODUCT_VARIANT_UPDATED</code>
</li>
<li>
<code>- PRODUCT_VARIANT_DELETED</code>
</li>
</ul>
</Box>
<MainInstructions marginTop={4} __marginBottom={"100px"} />
<AppSection
includePadding
heading="Webhooks status"
sideContent={<WebhooksStatusInstructions />}
mainContent={<WebhooksStatus />}
/>
<AppSection
marginTop={13}
heading="Algolia settings"
mainContent={<AlgoliaConfigurationForm />}
sideContent={

View file

@ -1333,6 +1333,9 @@ importers:
eslint-config-saleor:
specifier: workspace:*
version: link:../../packages/eslint-config-saleor
node-mocks-http:
specifier: ^1.12.2
version: 1.12.2
prettier:
specifier: ^2.7.1
version: 2.8.3