Webhook helpers (#949)

* WIP

* Added script and making implementation more roboust

* Added rollback on issues with the migration

* Cleanup the code

* Use allSettled instead of all

* Do not check spelling in schema files.
Schema is pulled from the API and is not controlled by our team

* Update the pkg json

* Fix typo on log message

* Add dedupe to the ignored words.
Its used by codegen

* Add changesets
This commit is contained in:
Krzysztof Wolski 2023-09-07 13:04:23 +02:00 committed by GitHub
parent a32fe7caf4
commit 7e0755ec9e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 73253 additions and 917 deletions

View file

@ -0,0 +1,5 @@
---
"saleor-app-search": patch
---
Webhook migration scripts has been moved to the shared package.

View file

@ -0,0 +1,5 @@
---
"@saleor/webhook-utils": patch
---
Introduced a new shared package with helpers used for webhook management.

View file

@ -6,7 +6,12 @@ const isSentryPropertiesInEnvironment =
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
transpilePackages: ["@saleor/apps-shared", "@saleor/apps-ui", "@saleor/react-hook-form-macaw"],
transpilePackages: [
"@saleor/apps-shared",
"@saleor/apps-ui",
"@saleor/webhook-utils",
"@saleor/react-hook-form-macaw",
],
};
const configWithSentry = withSentryConfig(
@ -22,7 +27,7 @@ const configWithSentry = withSentryConfig(
tunnelRoute: "/monitoring",
hideSourceMaps: true,
disableLogger: true,
}
},
);
module.exports = isSentryPropertiesInEnvironment ? configWithSentry : nextConfig;

View file

@ -18,6 +18,7 @@
"@saleor/apps-ui": "workspace:*",
"@saleor/macaw-ui": "0.8.0-pre.127",
"@saleor/react-hook-form-macaw": "workspace:*",
"@saleor/webhook-utils": "workspace:*",
"@sentry/nextjs": "7.67.0",
"@tanstack/react-query": "4.29.19",
"@trpc/client": "10.38.1",

View file

@ -1,52 +0,0 @@
/* eslint-disable turbo/no-undeclared-env-vars */
import { createGraphQLClient } from "@saleor/apps-shared";
import { WebhookActivityTogglerService } from "../../src/domain/WebhookActivityToggler.service";
import { FetchOwnWebhooksDocument } from "../../generated/graphql";
import { AuthData } from "@saleor/app-sdk/APL";
export const recreateWebhooks = async ({
authData,
dryRun,
}: {
authData: AuthData;
dryRun: boolean;
}) => {
console.log("Working on env: ", authData.saleorApiUrl);
const client = createGraphQLClient({
saleorApiUrl: authData.saleorApiUrl,
token: authData.token,
});
const webhooks = await client
.query(FetchOwnWebhooksDocument, {
id: authData.appId,
})
.toPromise()
.then((r) => r.data?.app?.webhooks);
if (!webhooks?.length) {
console.error("The environment does not have any webhooks, skipping");
return;
}
// Use currently existing webhook data to determine a proper baseUrl and enabled state
const targetUrl = webhooks[0].targetUrl;
const enabled = webhooks[0].isActive;
const baseUrl = new URL(targetUrl).origin;
if (dryRun) {
console.log("Necessary data gathered, skipping recreation of webhooks due to dry run mode");
return;
}
const webhookService = new WebhookActivityTogglerService(authData.appId, client);
try {
await webhookService.recreateOwnWebhooks({ baseUrl, enableWebhooks: enabled });
console.log("✅ Webhooks recreated successfully");
} catch (e) {
console.error("🛑 Failed to recreate webhooks: ", e);
}
};

View file

@ -2,7 +2,7 @@
import * as dotenv from "dotenv";
import { fetchCloudAplEnvs, verifyRequiredEnvs } from "./migration-utils";
import { recreateWebhooks } from "./recreate-webhooks";
import { updateWebhooksScript } from "./update-webhooks";
dotenv.config();
@ -21,7 +21,7 @@ const runMigration = async () => {
});
for (const env of allEnvs) {
await recreateWebhooks({ authData: env, dryRun: true });
await updateWebhooksScript({ authData: env, dryRun: true });
}
console.log("Migration dry run complete");

View file

@ -2,7 +2,7 @@
import * as dotenv from "dotenv";
import { fetchCloudAplEnvs, verifyRequiredEnvs } from "./migration-utils";
import { recreateWebhooks } from "./recreate-webhooks";
import { updateWebhooksScript } from "./update-webhooks";
dotenv.config();
@ -21,7 +21,7 @@ const runMigration = async () => {
});
for (const env of allEnvs) {
await recreateWebhooks({ authData: env, dryRun: false });
await updateWebhooksScript({ authData: env, dryRun: false });
}
console.log("Migration complete");

View file

@ -0,0 +1,47 @@
/* eslint-disable turbo/no-undeclared-env-vars */
import { createGraphQLClient } from "@saleor/apps-shared";
import { AuthData } from "@saleor/app-sdk/APL";
import { webhookMigrationRunner } from "@saleor/webhook-utils";
import { appWebhooks } from "../../webhooks";
export const updateWebhooksScript = async ({
authData,
dryRun,
}: {
authData: AuthData;
dryRun: boolean;
}) => {
console.log("Working on env: ", authData.saleorApiUrl);
const client = createGraphQLClient({
saleorApiUrl: authData.saleorApiUrl,
token: authData.token,
});
await webhookMigrationRunner({
client,
dryRun,
getManifests: async ({ appDetails }) => {
const webhooks = appDetails.webhooks;
if (!webhooks?.length) {
console.info("The environment does not have any webhooks, skipping");
return [];
}
// All webhooks in this application are turned on or off. If any of them is enabled, we enable all of them.
const enabled = webhooks.some((w) => w.isActive);
const targetUrl = appDetails.appUrl;
if (!targetUrl?.length) {
throw new Error("App has no defined appUrl, skipping");
}
const baseUrl = new URL(targetUrl).origin;
return appWebhooks.map((w) => ({ ...w.getWebhookManifest(baseUrl), enabled }));
},
});
};

View file

@ -68,7 +68,8 @@
"urql",
"Protos",
"pino",
"IFRAME"
"IFRAME",
"dedupe"
],
"ignorePaths": [
"node_modules",
@ -80,6 +81,7 @@
"**/*.test.ts",
"**/*.spec.ts",
"**/graphql.ts",
"**/CHANGELOG.md"
"**/CHANGELOG.md",
"**/schema.graphql"
]
}

View file

@ -39,15 +39,15 @@
"*.{ts,tsx,md,js,jsx}": "cspell --no-must-find-files"
},
"packageManager": "pnpm@8.7.4",
"pnpm": {
"overrides": {
"@saleor/app-sdk": "0.43.1"
}
},
"private": true,
"workspaces": [
"apps/*",
"packages/*",
"templates/*"
],
"pnpm": {
"overrides": {
"@saleor/app-sdk": "0.43.1"
}
}
]
}

View file

@ -0,0 +1,4 @@
{
"root": true,
"extends": ["saleor"]
}

View file

@ -0,0 +1,17 @@
schema: graphql/schema.graphql
documents: [graphql/**/*.graphql, src/**/*.ts, src/**/*.tsx]
extensions:
codegen:
overwrite: true
generates:
generated/graphql.ts:
config:
dedupeFragments: true
dedupeOperationSuffix: true
plugins:
- typescript
- typescript-operations
- typed-document-node
generated/schema.graphql:
plugins:
- schema-ast

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,16 @@
fragment AppDetailsFragment on App {
id
appUrl
name
webhooks{
...WebhookDetailsFragment
}
metadata{
key
value
}
privateMetadata{
key
value
}
}

View file

@ -0,0 +1,6 @@
fragment ChannelDetailsFragment on Channel {
id
slug
name
currencyCode
}

View file

@ -0,0 +1,6 @@
fragment ShopDetailsFragment on Shop {
version
domain {
url
}
}

View file

@ -0,0 +1,15 @@
fragment WebhookDetailsFragment on Webhook {
id
isActive
name
targetUrl
subscriptionQuery
syncEvents{
name
eventType
}
asyncEvents {
name
eventType
}
}

View file

@ -0,0 +1,12 @@
mutation CreateAppWebhook($input: WebhookCreateInput!) {
webhookCreate(input:$input){
webhook{
...WebhookDetailsFragment
}
errors{
code
field
message
}
}
}

View file

@ -0,0 +1,10 @@
mutation ModifyAppWebhook($id: ID!, $input: WebhookUpdateInput!) {
webhookUpdate(id: $id, input: $input) {
errors {
message
}
webhook {
...WebhookDetailsFragment
}
}
}

View file

@ -0,0 +1,8 @@
mutation RemoveAppWebhook($id: ID!) {
webhookDelete(id: $id){
errors{
field
message
}
}
}

View file

@ -0,0 +1,5 @@
query GetAppDetailsAndWebhooksData {
app{
...AppDetailsFragment
}
}

View file

@ -0,0 +1,8 @@
query GetSaleorInstanceData {
shop{
...ShopDetailsFragment
}
channels{
...ChannelDetailsFragment
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,5 @@
export * from "./src/operations/create-app-webhook";
export * from "./src/operations/modify-app-webhook";
export * from "./src/operations/remove-app-webhook";
export * from "./src/operations/get-app-details-and-webhooks-data";
export * from "./src/webhook-migration-runner";

View file

@ -0,0 +1,46 @@
{
"name": "@saleor/webhook-utils",
"version": "0.0.0",
"scripts": {
"fetch-schema": "curl https://raw.githubusercontent.com/saleor/saleor/${npm_package_saleor_schemaVersion}/saleor/graphql/schema.graphql > graphql/schema.graphql",
"generate": "graphql-codegen",
"lint:fix": "eslint --fix .",
"test": "vitest"
},
"dependencies": {
"@saleor/app-sdk": "0.41.1",
"@saleor/apps-shared": "workspace:*",
"@urql/exchange-auth": "^2.1.4",
"eslint": "8.46.0",
"graphql": "16.7.1",
"graphql-tag": "^2.12.6",
"pino": "^8.14.1",
"pino-pretty": "^10.0.0",
"semver": "^7.5.1",
"urql": "^4.0.4"
},
"devDependencies": {
"@graphql-codegen/cli": "4.0.1",
"@graphql-codegen/schema-ast": "4.0.0",
"@graphql-codegen/typed-document-node": "5.0.1",
"@graphql-codegen/typescript": "4.0.1",
"@graphql-codegen/typescript-operations": "4.0.1",
"@graphql-typed-document-node/core": "3.2.0",
"@saleor/app-sdk": "0.41.1",
"@types/semver": "^7.5.0",
"eslint-config-saleor": "workspace:*",
"typescript": "5.1.6",
"urql": "^4.0.4",
"vite": "4.4.8",
"vitest": "0.34.1"
},
"main": "index.ts",
"peerDependencies": {
"next": "13.3.0",
"pino": "^8.14.1",
"pino-pretty": "^10.0.0"
},
"saleor": {
"schemaVersion": "3.7"
}
}

View file

@ -0,0 +1,26 @@
import { Client } from "urql";
import { WebhookEventTypeAsyncEnum, WebhookEventTypeSyncEnum } from "../generated/graphql";
import { WebhookManifest } from "@saleor/app-sdk/types";
import { createAppWebhook } from "./operations/create-app-webhook";
interface CreateAppWebhookFromManifestArgs {
client: Client;
webhookManifest: WebhookManifest;
}
export const createAppWebhookFromManifest = async ({
client,
webhookManifest,
}: CreateAppWebhookFromManifestArgs) => {
return createAppWebhook({
client,
input: {
asyncEvents: webhookManifest.asyncEvents as WebhookEventTypeAsyncEnum[],
syncEvents: webhookManifest.syncEvents as WebhookEventTypeSyncEnum[],
isActive: webhookManifest.isActive,
name: webhookManifest.name,
targetUrl: webhookManifest.targetUrl,
query: webhookManifest.query,
},
});
};

View file

@ -0,0 +1,25 @@
import { Client } from "urql";
import { WebhookDetailsFragment } from "../generated/graphql";
import { createAppWebhook } from "./operations/create-app-webhook";
interface CreateAppWebhookFromWebhookDetailsFragmentArgs {
client: Client;
webhookDetails: WebhookDetailsFragment;
}
export const createAppWebhookFromWebhookDetailsFragment = async ({
client,
webhookDetails,
}: CreateAppWebhookFromWebhookDetailsFragmentArgs) => {
return createAppWebhook({
client,
input: {
asyncEvents: webhookDetails.asyncEvents.map((event) => event.eventType),
syncEvents: webhookDetails.syncEvents.map((event) => event.eventType),
isActive: webhookDetails.isActive,
name: webhookDetails.name,
targetUrl: webhookDetails.targetUrl,
query: webhookDetails.subscriptionQuery,
},
});
};

View file

@ -0,0 +1,17 @@
import { Client } from "urql";
import { modifyAppWebhook } from "./operations/modify-app-webhook";
interface DisableWebhookArgs {
client: Client;
webhookId: string;
}
export const disableWebhook = async ({ client, webhookId }: DisableWebhookArgs) => {
return modifyAppWebhook({
client,
webhookId,
input: {
isActive: false,
},
});
};

View file

@ -0,0 +1,17 @@
import { Client } from "urql";
import { modifyAppWebhook } from "./operations/modify-app-webhook";
interface enableWebhookArgs {
client: Client;
webhookId: string;
}
export const enableWebhook = async ({ client, webhookId }: enableWebhookArgs) => {
await modifyAppWebhook({
client,
webhookId,
input: {
isActive: true,
},
});
};

View file

@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import { getWebhookIdsAndManifestsToUpdate } from "./get-webhook-ids-and-manifests-to-update";
describe("getWebhookIdsAndQueriesToUpdate", () => {
it("Returns an empty list, when no data is passed", () => {
expect(
getWebhookIdsAndManifestsToUpdate({
existingWebhooksPartial: [],
newWebhookManifests: [],
}),
).toStrictEqual([]);
});
it("Returns all of the entries, when new webhook manifests contain the same webhooks as existing list", () => {
expect(
getWebhookIdsAndManifestsToUpdate({
existingWebhooksPartial: [
{ id: "1", name: "webhook1" },
{ id: "2", name: "webhook2" },
],
newWebhookManifests: [
{
asyncEvents: [],
isActive: true,
name: "webhook1",
query: "newQuery1",
syncEvents: [],
targetUrl: "",
},
{
asyncEvents: [],
isActive: true,
name: "webhook2",
query: "newQuery2",
syncEvents: [],
targetUrl: "",
},
],
}),
).toStrictEqual([
{
webhookId: "1",
webhookManifest: {
asyncEvents: [],
isActive: true,
name: "webhook1",
query: "newQuery1",
syncEvents: [],
targetUrl: "",
},
},
{
webhookId: "2",
webhookManifest: {
asyncEvents: [],
isActive: true,
name: "webhook2",
query: "newQuery2",
syncEvents: [],
targetUrl: "",
},
},
]);
});
it("Returns subset of entries, when existing webhook list contain some of them", () => {
expect(
getWebhookIdsAndManifestsToUpdate({
existingWebhooksPartial: [{ id: "1", name: "webhook1" }],
newWebhookManifests: [
{
asyncEvents: [],
isActive: true,
name: "webhook1",
query: "newQuery1",
syncEvents: [],
targetUrl: "",
},
{
asyncEvents: [],
isActive: true,
name: "webhook2",
query: "newQuery2",
syncEvents: [],
targetUrl: "",
},
],
}),
).toStrictEqual([
{
webhookId: "1",
webhookManifest: {
asyncEvents: [],
isActive: true,
name: "webhook1",
query: "newQuery1",
syncEvents: [],
targetUrl: "",
},
},
]);
});
});

View file

@ -0,0 +1,38 @@
import { WebhookManifest } from "@saleor/app-sdk/types";
type WebhookPartial = { id: string; name: string };
interface GetWebhookIdsAndQueriesToUpdateArgs<T extends WebhookPartial> {
newWebhookManifests: Array<WebhookManifest>;
existingWebhooksPartial: Array<T>;
}
type ReturnType = { webhookId: string; webhookManifest: WebhookManifest };
// Couples the webhook id with the manifest to update
export const getWebhookIdsAndManifestsToUpdate = <T extends WebhookPartial>({
newWebhookManifests,
existingWebhooksPartial,
}: GetWebhookIdsAndQueriesToUpdateArgs<T>): Array<ReturnType> => {
return newWebhookManifests
.map((webhookManifest) => {
const existingWebhook = existingWebhooksPartial.find(
(webhook) => webhook.name === webhookManifest.name,
);
if (!existingWebhook) {
// Theres no webhook with this name, so we cant start an update
return undefined;
}
if (!webhookManifest.query) {
return undefined;
}
return {
webhookId: existingWebhook.id,
webhookManifest,
};
})
.filter((data): data is ReturnType => data !== undefined); // Filter out undefined values and narrow down the type
};

View file

@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { webhooksToAdd } from "./webhooks-to-add";
describe("webhooksToAdd", () => {
it("Returns an empty list, when no data is passed", () => {
expect(
webhooksToAdd({
existingWebhooksPartial: [],
newWebhookManifests: [],
}),
).toStrictEqual([]);
});
it("Returns empty list, when new webhook manifests contain the same webhooks as existing list", () => {
expect(
webhooksToAdd({
existingWebhooksPartial: [
{ id: "1", name: "webhook1" },
{ id: "1", name: "webhook2" },
],
newWebhookManifests: [
{
asyncEvents: [],
isActive: true,
name: "webhook1",
query: "",
syncEvents: [],
targetUrl: "",
},
{
asyncEvents: [],
isActive: true,
name: "webhook2",
query: "",
syncEvents: [],
targetUrl: "",
},
],
}),
).toStrictEqual([]);
});
it("Returns all of the manifests, when existing webhook list is empty", () => {
expect(
webhooksToAdd({
existingWebhooksPartial: [],
newWebhookManifests: [
{
asyncEvents: [],
isActive: true,
name: "webhook1",
query: "",
syncEvents: [],
targetUrl: "",
},
{
asyncEvents: [],
isActive: true,
name: "webhook2",
query: "",
syncEvents: [],
targetUrl: "",
},
],
}),
).toStrictEqual([
{
asyncEvents: [],
isActive: true,
name: "webhook1",
query: "",
syncEvents: [],
targetUrl: "",
},
{
asyncEvents: [],
isActive: true,
name: "webhook2",
query: "",
syncEvents: [],
targetUrl: "",
},
]);
});
it("Returns list with the new webhook to add, when it was not specified in the existing manifests", () => {
expect(
webhooksToAdd({
existingWebhooksPartial: [{ id: "1", name: "webhookOld" }],
newWebhookManifests: [
{
asyncEvents: [],
isActive: true,
name: "webhookOld",
query: "",
syncEvents: [],
targetUrl: "",
},
{
asyncEvents: [],
isActive: true,
name: "webhookNew",
query: "",
syncEvents: [],
targetUrl: "",
},
],
}),
).toStrictEqual([
{
asyncEvents: [],
isActive: true,
name: "webhookNew",
query: "",
syncEvents: [],
targetUrl: "",
},
]);
});
});

View file

@ -0,0 +1,18 @@
import { WebhookManifest } from "@saleor/app-sdk/types";
interface WebhooksToAddArgs {
newWebhookManifests: Array<WebhookManifest>;
existingWebhooksPartial: Array<{ id: string; name: string }>;
}
export const webhooksToAdd = ({
newWebhookManifests,
existingWebhooksPartial,
}: WebhooksToAddArgs) => {
return newWebhookManifests.filter(
(newWebhookManifest) =>
!existingWebhooksPartial.find(
(existingWebhook) => newWebhookManifest.name === existingWebhook.name,
),
);
};

View file

@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { webhooksToRemove } from "./webhooks-to-remove";
describe("webhooksToRemove", () => {
it("Returns an empty list, when no data is passed", () => {
expect(
webhooksToRemove({
existingWebhooksPartial: [],
newWebhookManifests: [],
}),
).toStrictEqual([]);
});
it("Returns empty list, when new webhook manifests contain the same webhooks as existing list", () => {
expect(
webhooksToRemove({
existingWebhooksPartial: [
{ id: "1", name: "webhook1" },
{ id: "1", name: "webhook2" },
],
newWebhookManifests: [
{
asyncEvents: [],
isActive: true,
name: "webhook1",
query: "",
syncEvents: [],
targetUrl: "",
},
{
asyncEvents: [],
isActive: true,
name: "webhook2",
query: "",
syncEvents: [],
targetUrl: "",
},
],
}),
).toStrictEqual([]);
});
it("Returns all of the existing webhooks, when new webhook manifests list is empty", () => {
expect(
webhooksToRemove({
existingWebhooksPartial: [
{ id: "1", name: "webhook1" },
{ id: "2", name: "webhook2" },
],
newWebhookManifests: [],
}),
).toStrictEqual([
{ id: "1", name: "webhook1" },
{ id: "2", name: "webhook2" },
]);
});
it("Returns list with the webhook one webhook to remove, when it was not specified in the new manifests", () => {
expect(
webhooksToRemove({
existingWebhooksPartial: [
{ id: "1", name: "webhook1" },
{ id: "2", name: "webhook2" },
],
newWebhookManifests: [
{
asyncEvents: [],
isActive: true,
name: "webhook1",
query: "",
syncEvents: [],
targetUrl: "",
},
],
}),
).toStrictEqual([{ id: "2", name: "webhook2" }]);
});
});

View file

@ -0,0 +1,22 @@
import { WebhookManifest } from "@saleor/app-sdk/types";
type WebhookPartial = { id: string; name: string };
interface WebhooksToRemoveArgs<T extends WebhookPartial> {
newWebhookManifests: Array<WebhookManifest>;
existingWebhooksPartial: Array<T>;
}
/*
* Returns partials of the existing webhooks which are not specified in the manifests.
* The comparison is based on webhook names.
*/
export const webhooksToRemove = <T extends WebhookPartial>({
newWebhookManifests,
existingWebhooksPartial,
}: WebhooksToRemoveArgs<T>) => {
return existingWebhooksPartial.filter(
(existingWebhook) =>
!newWebhookManifests.find((webhookManifest) => webhookManifest.name === existingWebhook.name),
);
};

View file

@ -0,0 +1,29 @@
import { Client } from "urql";
import { WebhookEventTypeAsyncEnum, WebhookEventTypeSyncEnum } from "../generated/graphql";
import { WebhookManifest } from "@saleor/app-sdk/types";
import { modifyAppWebhook } from "./operations/modify-app-webhook";
interface ModifyAppWebhookFromManifestArgs {
client: Client;
webhookId: string;
webhookManifest: WebhookManifest;
}
export const modifyAppWebhookFromManifest = async ({
client,
webhookId,
webhookManifest,
}: ModifyAppWebhookFromManifestArgs) => {
return modifyAppWebhook({
client,
webhookId,
input: {
asyncEvents: webhookManifest.asyncEvents as WebhookEventTypeAsyncEnum[],
syncEvents: webhookManifest.syncEvents as WebhookEventTypeSyncEnum[],
isActive: webhookManifest.isActive,
name: webhookManifest.name,
targetUrl: webhookManifest.targetUrl,
query: webhookManifest.query,
},
});
};

View file

@ -0,0 +1,26 @@
import { Client } from "urql";
import { WebhookDetailsFragment } from "../generated/graphql";
import { modifyAppWebhook } from "./operations/modify-app-webhook";
interface ModifyAppWebhookFromWebhookDetailsArgs {
client: Client;
webhookDetails: WebhookDetailsFragment;
}
export const modifyAppWebhookFromWebhookDetails = async ({
client,
webhookDetails,
}: ModifyAppWebhookFromWebhookDetailsArgs) => {
return modifyAppWebhook({
client,
webhookId: webhookDetails.id,
input: {
asyncEvents: webhookDetails.asyncEvents.map((event) => event.eventType),
syncEvents: webhookDetails.syncEvents.map((event) => event.eventType),
isActive: webhookDetails.isActive,
name: webhookDetails.name,
targetUrl: webhookDetails.targetUrl,
query: webhookDetails.subscriptionQuery,
},
});
};

View file

@ -0,0 +1,28 @@
import { Client } from "urql";
import { CreateAppWebhookDocument, WebhookCreateInput } from "../../generated/graphql";
interface CreateAppWebhookArgs {
client: Client;
input: WebhookCreateInput;
}
export const createAppWebhook = async ({ client, input }: CreateAppWebhookArgs) => {
return client
.mutation(CreateAppWebhookDocument, {
input,
})
.toPromise()
.then((r) => {
if (r.error) {
throw new Error(`Webhook creation failed. The API returned an error: ${r.error.message}`);
}
const webhook = r.data?.webhookCreate?.webhook;
if (!webhook) {
throw new Error(
"Webhook creation response is empty. The API returned no additional error.",
);
}
return webhook;
});
};

View file

@ -0,0 +1,22 @@
import { Client } from "urql";
import { GetAppDetailsAndWebhooksDataDocument } from "../../generated/graphql";
interface GetAppWebhooksArgs {
client: Client;
}
export const getAppDetailsAndWebhooksData = async ({ client }: GetAppWebhooksArgs) => {
return client
.query(GetAppDetailsAndWebhooksDataDocument, {})
.toPromise()
.then((r) => {
const app = r.data?.app;
if (!app) {
throw new Error(
"Could not get the app details. Access token could be invalid or app is disabled.",
);
}
return app;
});
};

View file

@ -0,0 +1,27 @@
import { Client } from "urql";
import {
GetAppDetailsAndWebhooksDataDocument,
GetSaleorInstanceDataDocument,
} from "../../generated/graphql";
interface GetSaleorInstanceDetailsArgs {
client: Client;
}
export const getSaleorInstanceDetails = async ({ client }: GetSaleorInstanceDetailsArgs) => {
return client
.query(GetSaleorInstanceDataDocument, {})
.toPromise()
.then((r) => {
if (r.error) {
throw new Error(
`Getting Saleor Instance details failed. The API returned an error: ${r.error.message}`,
);
}
return {
shop: r.data?.shop,
channels: r.data?.channels || [],
};
});
};

View file

@ -0,0 +1,29 @@
import { Client } from "urql";
import { ModifyAppWebhookDocument, WebhookUpdateInput } from "../../generated/graphql";
interface ModifyAppWebhookArgs {
client: Client;
webhookId: string;
input: WebhookUpdateInput;
}
export const modifyAppWebhook = async ({ client, webhookId, input }: ModifyAppWebhookArgs) => {
return client
.mutation(ModifyAppWebhookDocument, {
id: webhookId,
input,
})
.toPromise()
.then((r) => {
if (r.error) {
throw new Error(`Webhook creation failed. The API returned an error: ${r.error.message}`);
}
const webhook = r.data?.webhookUpdate?.webhook;
if (!webhook) {
throw new Error("Webhook modify response is empty. The API returned no additional error.");
}
return webhook;
});
};

View file

@ -0,0 +1,22 @@
import { Client } from "urql";
import { RemoveAppWebhookDocument } from "../../generated/graphql";
interface RemoveAppWebhookArgs {
client: Client;
webhookId: string;
}
export const removeAppWebhook = async ({ client, webhookId }: RemoveAppWebhookArgs) => {
return client
.mutation(RemoveAppWebhookDocument, {
id: webhookId,
})
.toPromise()
.then((r) => {
if (r.error) {
throw new Error(`Webhook creation failed. The API returned an error: ${r.error.message}`);
}
return;
});
};

View file

@ -0,0 +1,6 @@
/**
* Add test setup logic here
*
* https://vitest.dev/config/#setupfiles
*/
export {};

View file

@ -0,0 +1,166 @@
import { WebhookManifest } from "@saleor/app-sdk/types";
import { Client } from "urql";
import { webhooksToRemove } from "./filters/webhooks-to-remove";
import { getWebhookIdsAndManifestsToUpdate } from "./filters/get-webhook-ids-and-manifests-to-update";
import { webhooksToAdd } from "./filters/webhooks-to-add";
import { createLogger } from "@saleor/apps-shared";
import { removeAppWebhook } from "./operations/remove-app-webhook";
import { WebhookDetailsFragment } from "../generated/graphql";
import { createAppWebhookFromManifest } from "./create-app-webhook-from-manifest";
import { modifyAppWebhookFromManifest } from "./modify-app-webhook-from-manifest";
import { createAppWebhookFromWebhookDetailsFragment } from "./create-app-webhook-from-webhook-details-fragment";
import { modifyAppWebhookFromWebhookDetails } from "./modify-app-webhook-from-webhook-details";
const logger = createLogger({ name: "updateWebhooks" });
interface RollbackArgs {
client: Client;
webhookManifests: Array<WebhookManifest>;
existingWebhooksData: Array<WebhookDetailsFragment>;
addedWebhooks: Array<WebhookDetailsFragment>;
modifiedWebhooks: Array<WebhookDetailsFragment>;
removedWebhooks: Array<WebhookDetailsFragment>;
}
const rollback = async ({
client,
addedWebhooks,
modifiedWebhooks,
existingWebhooksData,
removedWebhooks,
}: RollbackArgs) => {
if (addedWebhooks.length) {
logger.info("Removing added webhooks");
await Promise.allSettled(
addedWebhooks.map((webhook) => removeAppWebhook({ client, webhookId: webhook.id })),
);
}
if (modifiedWebhooks.length) {
logger.info("Rollback modified webhooks");
await Promise.allSettled(
modifiedWebhooks.map((modifiedWebhook) => {
const webhookDetails = existingWebhooksData.find(
(existingWebhook) => existingWebhook.id === modifiedWebhook.id,
);
if (!webhookDetails) {
logger.error("This should not happen");
throw new Error("This should not happen");
}
return modifyAppWebhookFromWebhookDetails({ client, webhookDetails });
}),
);
}
if (removedWebhooks.length) {
logger.debug("Rollback removed webhooks");
await Promise.allSettled(
modifiedWebhooks.map((webhookDetails) => {
return createAppWebhookFromWebhookDetailsFragment({
client,
webhookDetails,
});
}),
);
}
};
interface UpdateWebhooksArgs {
client: Client;
webhookManifests: Array<WebhookManifest>;
existingWebhooksData: Array<WebhookDetailsFragment>;
dryRun?: boolean;
}
/*
* Based on given list of existing and new webhooks:
* - remove the ones which are not in the new list
* - create the ones which are not in the existing list
* - update queries the ones which are in both lists
*
* If any of the operations fails, rollback all changes to the initial state
*/
export const updateWebhooks = async ({
client,
webhookManifests,
existingWebhooksData,
dryRun,
}: UpdateWebhooksArgs) => {
const addedWebhooks = [];
const modifiedWebhooks = [];
const removedWebhooks = [];
try {
logger.debug("Preparing list of changes to be executed");
// Based on names, find the ones which should be added
const webhookManifestsToBeAdded = webhooksToAdd({
existingWebhooksPartial: existingWebhooksData,
newWebhookManifests: webhookManifests,
});
// Based on names, find the ones which should be updated
const webhookIdsAndManifestsToBeUpdated = getWebhookIdsAndManifestsToUpdate({
existingWebhooksPartial: existingWebhooksData,
newWebhookManifests: webhookManifests,
});
// Based on names, find the ones which should be removed
const webhookToBeRemoved = webhooksToRemove({
existingWebhooksPartial: existingWebhooksData,
newWebhookManifests: webhookManifests,
});
logger.info(
`Scheduled changes: ${webhookIdsAndManifestsToBeUpdated.length} to be updated, ${webhookManifestsToBeAdded.length} to be added, ${webhookToBeRemoved.length} to be removed`,
);
if (dryRun) {
logger.info("Dry run mode, changes will not be executed. Exiting.");
return;
}
for (const webhookManifest of webhookManifestsToBeAdded) {
logger.debug(`Adding webhook ${webhookManifest.name}`);
const createdWebhook = await createAppWebhookFromManifest({ client, webhookManifest });
logger.debug("Webhook added");
addedWebhooks.push(createdWebhook);
}
for (const updateData of webhookIdsAndManifestsToBeUpdated) {
const { webhookId, webhookManifest } = updateData;
logger.debug(`Updating webhook ${webhookManifest.name}`);
const response = await modifyAppWebhookFromManifest({ client, webhookId, webhookManifest });
logger.debug("Webhook updated");
modifiedWebhooks.push(response);
}
for (const webhookDetails of webhookToBeRemoved) {
logger.debug(`Removing webhook ${webhookDetails.name}`);
await removeAppWebhook({ client, webhookId: webhookDetails.id });
logger.debug("Webhook removed");
removedWebhooks.push(webhookDetails);
}
logger.info("Migration finished successfully");
} catch (e) {
logger.error(e, "Error during update procedure, rolling back changes");
await rollback({
client,
addedWebhooks,
existingWebhooksData,
modifiedWebhooks,
webhookManifests,
removedWebhooks,
});
logger.info("Changes rolled back");
}
};

View file

@ -0,0 +1,59 @@
import { Client } from "urql";
import { AppDetailsFragment } from "../generated/graphql";
import { WebhookManifest } from "@saleor/app-sdk/types";
import { getAppDetailsAndWebhooksData } from "./operations/get-app-details-and-webhooks-data";
import { createLogger } from "@saleor/apps-shared";
import { updateWebhooks } from "./update-webhooks";
const logger = createLogger({ name: "updateScript" });
interface WebhookMigrationRunnerArgs {
client: Client;
getManifests: ({
appDetails,
}: {
appDetails: AppDetailsFragment;
}) => Promise<Array<WebhookManifest>>;
dryRun?: boolean;
}
export const webhookMigrationRunner = async ({
client,
getManifests,
dryRun,
}: WebhookMigrationRunnerArgs) => {
logger.info("Getting app details and webhooks data");
let appDetails: AppDetailsFragment | undefined;
try {
appDetails = await getAppDetailsAndWebhooksData({ client });
} catch (e) {
logger.error(e, "Couldn't fetch the app details.");
return;
}
if (!appDetails) {
logger.error("No app details.");
return;
}
logger.debug("Got app details and webhooks data. Generate list of webhook manifests");
let newWebhookManifests: Array<WebhookManifest> = [];
try {
newWebhookManifests = await getManifests({ appDetails });
} catch (e) {
logger.error(e, "Couldn't prepare list of manifests.");
return;
}
logger.debug("Got list of webhook manifests. Updating webhooks");
await updateWebhooks({
client,
webhookManifests: newWebhookManifests,
existingWebhooksData: appDetails.webhooks || [],
dryRun,
});
logger.info("Migration finished.");
};

View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,12 @@
import { defineConfig } from "vitest/config";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [],
test: {
passWithNoTests: true,
environment: "jsdom",
setupFiles: "./src/setup-tests.ts",
css: false,
},
});

File diff suppressed because it is too large Load diff