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:
parent
3347a305cd
commit
40bed99316
35 changed files with 1048 additions and 115 deletions
13
.changeset/grumpy-suits-sing.md
Normal file
13
.changeset/grumpy-suits-sing.md
Normal 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
|
||||
|
26
apps/search/graphql/fragments/OwnWebhook.graphql
Normal file
26
apps/search/graphql/fragments/OwnWebhook.graphql
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
11
apps/search/graphql/mutations/DisableWebhook.graphql
Normal file
11
apps/search/graphql/mutations/DisableWebhook.graphql
Normal file
|
@ -0,0 +1,11 @@
|
|||
mutation DisableWebhook($id: ID!) {
|
||||
webhookUpdate(id: $id, input: { isActive: false }) {
|
||||
errors {
|
||||
message
|
||||
}
|
||||
webhook {
|
||||
id
|
||||
isActive
|
||||
}
|
||||
}
|
||||
}
|
11
apps/search/graphql/mutations/EnableWebhook.graphql
Normal file
11
apps/search/graphql/mutations/EnableWebhook.graphql
Normal file
|
@ -0,0 +1,11 @@
|
|||
mutation EnableWebhook($id: ID!) {
|
||||
webhookUpdate(id: $id, input: { isActive: true }) {
|
||||
errors {
|
||||
message
|
||||
}
|
||||
webhook {
|
||||
id
|
||||
isActive
|
||||
}
|
||||
}
|
||||
}
|
7
apps/search/graphql/queries/FetchOwnWebhooks.graphql
Normal file
7
apps/search/graphql/queries/FetchOwnWebhooks.graphql
Normal file
|
@ -0,0 +1,7 @@
|
|||
query FetchOwnWebhooks($id: ID!) {
|
||||
app(id: $id) {
|
||||
webhooks {
|
||||
...OwnWebhook
|
||||
}
|
||||
}
|
||||
}
|
|
@ -7,5 +7,10 @@ subscription ProductCreated {
|
|||
...ProductWebhookPayload
|
||||
}
|
||||
}
|
||||
recipient {
|
||||
webhooks {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,5 +7,10 @@ subscription ProductDeleted {
|
|||
...ProductWebhookPayload
|
||||
}
|
||||
}
|
||||
recipient {
|
||||
webhooks {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,5 +7,10 @@ subscription ProductUpdated {
|
|||
...ProductWebhookPayload
|
||||
}
|
||||
}
|
||||
recipient {
|
||||
webhooks {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,5 +7,10 @@ subscription ProductVariantCreated {
|
|||
...ProductVariantWebhookPayload
|
||||
}
|
||||
}
|
||||
recipient {
|
||||
webhooks {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,5 +7,10 @@ subscription ProductVariantDeleted {
|
|||
...ProductVariantWebhookPayload
|
||||
}
|
||||
}
|
||||
recipient {
|
||||
webhooks {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,5 +7,10 @@ subscription ProductVariantUpdated {
|
|||
...ProductVariantWebhookPayload
|
||||
}
|
||||
}
|
||||
recipient {
|
||||
webhooks {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -52,9 +52,14 @@ export const AlgoliaConfigurationForm = () => {
|
|||
},
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
reactQueryClient.refetchQueries({
|
||||
queryKey: ["configuration"],
|
||||
});
|
||||
}),
|
||||
reactQueryClient.refetchQueries({
|
||||
queryKey: ["webhooks-status"],
|
||||
}),
|
||||
]);
|
||||
notifySuccess("Configuration saved!");
|
||||
},
|
||||
onError: async (data: Error) => {
|
||||
|
|
|
@ -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
|
||||
|
|
63
apps/search/src/components/MainInstructions.tsx
Normal file
63
apps/search/src/components/MainInstructions.tsx
Normal 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>
|
||||
);
|
||||
};
|
104
apps/search/src/components/WebhooksStatus.tsx
Normal file
104
apps/search/src/components/WebhooksStatus.tsx
Normal 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>
|
||||
);
|
||||
};
|
22
apps/search/src/components/WebhooksStatusInstructions.tsx
Normal file
22
apps/search/src/components/WebhooksStatusInstructions.tsx
Normal 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>;
|
||||
};
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
124
apps/search/src/domain/WebhookActivityToggler.service.ts
Normal file
124
apps/search/src/domain/WebhookActivityToggler.service.ts
Normal 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)));
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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">;
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
20
apps/search/src/lib/useWebhooksStatus.tsx
Normal file
20
apps/search/src/lib/useWebhooksStatus.tsx
Normal 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,
|
||||
});
|
||||
};
|
|
@ -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;
|
||||
|
||||
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");
|
||||
|
|
123
apps/search/src/pages/api/webhooks-status.ts
Normal file
123
apps/search/src/pages/api/webhooks-status.ts
Normal 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,
|
||||
[]
|
||||
);
|
|
@ -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) {
|
||||
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;
|
||||
|
|
|
@ -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) {
|
||||
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;
|
||||
|
|
|
@ -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) {
|
||||
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;
|
||||
};
|
||||
|
|
|
@ -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) {
|
||||
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;
|
||||
};
|
||||
|
|
|
@ -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) {
|
||||
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;
|
||||
};
|
||||
|
|
|
@ -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) {
|
||||
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;
|
||||
};
|
||||
|
|
125
apps/search/src/test/handlers/webhooks-status-handler.test.ts
Normal file
125
apps/search/src/test/handlers/webhooks-status-handler.test.ts
Normal 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);
|
||||
});
|
||||
});
|
|
@ -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={
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue