diff --git a/.changeset/afraid-poets-visit.md b/.changeset/afraid-poets-visit.md new file mode 100644 index 0000000..9cf6598 --- /dev/null +++ b/.changeset/afraid-poets-visit.md @@ -0,0 +1,15 @@ +--- +"@saleor/app-sdk": minor +--- + +Add SaleorSyncWebhook class + +Now app-sdk support both `new SaleorAsyncWebhook()` and `new SaleorSyncWebhook()`. + +Changes: + +Constructor field `subscriptionQueryAst?: ASTNode` in `SaleorAsyncWebhook` has been deprecated. + +Use `query` field instead (`query: ASTNode | string`) + +Constructor field `asyncEvent` has been deprecated. Use `event` instead diff --git a/.eslintrc b/.eslintrc index 50e3a37..6484184 100644 --- a/.eslintrc +++ b/.eslintrc @@ -58,7 +58,8 @@ "import/prefer-default-export": "off", "@typescript-eslint/no-misused-promises": ["error"], "@typescript-eslint/no-floating-promises": ["error"], - "class-methods-use-this": "off" + "class-methods-use-this": "off", + "no-new": "off" }, "settings": { "import/parsers": { diff --git a/docs/saleor-async-webhook.md b/docs/saleor-async-webhook.md deleted file mode 100644 index 654655e..0000000 --- a/docs/saleor-async-webhook.md +++ /dev/null @@ -1,182 +0,0 @@ -# Saleor Async Webhook - -Apps are usually connected via webhooks - one App sends a HTTP request to another App, informing about some event or requesting some action to be performed. - -To inform your App about events originated from Saleor, you need to expose a webhook handler, which Saleor will call with POST request. - -To avoid boilerplate, App SDK provides utility that abstracts connection details, allowing developers to focus on business logic. - -Note - this utility works for Saleor Async Webhooks only. Support for Sync webhooks are not yet supported in SDK, but you can write your sync webhook handler -from scratch. - -## Creating async webhook with SaleorAsyncWebhook - -### Creating webhook handler configuration - -To use SaleorAsyncWebhook utility, first create a new instance. It can be created in your API handler file - -```typescript -// pages/api/webhooks/order-created.ts - -/** - * To be type safe, define payload from API. This should be imported from generated graphQL code - */ -type OrderPayload = { - id: string; -}; - -export const orderCreatedWebhook = new SaleorAsyncWebhook({ - /** - * Name of the webhook, not required - */ - name: "Order Created", - /** - * Relative path to the webhook, required - */ - webhookPath: "api/webhooks/order-created", - /** - * Event type, required - */ - asyncEvent: "ORDER_CREATED", - /** - * Decide if webhook created during app installation should be active or not - */ - isActive: true, - /** - * Provide APL, read more below - */ - apl: require("../lib/apl"), - /** - * Subscription query, telling Saleor what payload app expects - */ - query: ` - subscription { - event { - ... on OrderCreated { - order { - id - } - } - } - } - `, - /** - * Optional - * - * Read internal errors - */ - onError(error: WebhookError | Error) { - // Can be used to e.g. trace errors - sentry.captureError(error); - }, - /** - * Optional - * Allows to set custom error response. If not provided, default mapping and message will be responsed - * if Webhook validation fails - */ - async formatErrorResponse( - error: WebhookError | Error, - req: NextApiRequest, - res: NextApiResponse - ) { - return { - code: 400, - body: "My custom response", - }; - }, -}); -``` - -- Check available events [here](https://docs.saleor.io/docs/3.x/developer/extending/apps/asynchronous-webhooks#available-webhook-events) -- [Read more about APLs](./apl.md) -- [Subscription query documentation](https://docs.saleor.io/docs/3.x/developer/extending/apps/subscription-webhook-payloads) - -You can consider created `orderCreatedWebhook` a center point of your webhook configuration. Now, you need to create a handler and add it to manifest. - -### Extending app manifest - -Webhooks are created in Saleor when the App is installed. Saleor uses [AppManifest](https://docs.saleor.io/docs/3.x/developer/extending/apps/manifest) to get information about webhooks to create. -`SaleorAsyncWebhook` utility can generate this manifest: - -```typescript -// pages/api/manifest - -import { createManifestHandler } from "@saleor/app-sdk/handlers/next"; - -import { orderCreatedWebhook } from "./order-created.ts"; - -export default createManifestHandler({ - manifestFactory({ appBaseUrl }) { - return { - /** - * Add one or more webhook manifests. - */ - webhooks: [orderCreatedWebhook.getWebhookManifest(appBaseUrl)], - // ...rest of your App's manifest - }; - }, -}); -``` - -Now, try to read your manifest, in default Next.js config it will be `GET localhost:3000/api/manifest`. You should see webhook configuration as part of manifest response. - -### Creating webhook domain logic - -Now, let's create a handler that will process webhook data. Let's back to handler file `pages/api/webhooks/order-created.ts`. - -```typescript -type OrderPayload = { - id: string; -}; - -export const orderCreatedWebhook = new SaleorAsyncWebhook({ - // ... your configuration -}); - -export default orderCreatedWebhook.createHandler((req, res, context) => { - const { baseUrl, event, payload, authData } = context; - - console.log(payload.id); // type is inferred - - // Perform some domain logic - - // End with status 200 - return res.status(200).end(); -}); -``` - -### query vs subscriptionQueryAst - -Subscription query can be specified using plain string or as `ASTNode` object created by `gql` tag. - -If your project does not use any code generation for GraphQL operations, use the string. In case you are using [GraphQL Code Generator](https://the-guild.dev/graphql/codegen), which we highly recommend, you should pass a subscription as GraphQL ASTNode: - -```typescript -/** - * Subscription query, you can define it in the `.ts` file. If you write operations in separate `.graphql` files, codegen will also export them in the generated file. - */ -export const ExampleProductUpdatedSubscription = gql` - ${ProductUpdatedWebhookPayload} - subscription ExampleProductUpdated { - event { - fragment - ProductUpdatedWebhookPayload - on - ProductUpdated { - product { - id - name - } - } - } - } -`; - -export const productUpdatedWebhook = new SaleorAsyncWebhook({ - name: "Example product updated webhook", - webhookPath: "api/webhooks/saleor/product-updated", - asyncEvent: "PRODUCT_UPDATED", - apl: saleorApp.apl, - subscriptionQueryAst: ExampleProductUpdatedSubscription, -}); -``` diff --git a/docs/saleor-webhook.md b/docs/saleor-webhook.md new file mode 100644 index 0000000..36097af --- /dev/null +++ b/docs/saleor-webhook.md @@ -0,0 +1,305 @@ +# Saleor Webhook utilities + +Apps are usually connected via webhooks - one App sends an HTTP request to another App, informing about some event or requesting some action to be performed. + +To inform your App about events originated from Saleor, you need to expose a webhook handler, which Saleor will call with POST request. + +The App SDK provides a utility that abstracts connection details and auth, allowing developers to focus on business logic. + +Depending on the type of the webhook, you can choose one of the classes: + +- `SaleorAsyncWebhook` +- `SaleorSyncWebhook` + +## Common configuration + +Both `SaleorSyncWebhook` and `SaleorAsyncWebhook` contain similar API with little differences. + +### Constructing Webhook instance + +In Next.js pages create a page, e.g. `pages/api/webhooks/order-created.ts`. We recommend to keep webhook type in file name, which will be resolved by Next.js to route path. + +```typescript +import { SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next"; + +/** + * To be type-safe, define payload from API. This should be imported from generated GraphQL code + */ +type OrderPayload = { + id: string; +}; + +export const orderCreatedWebhook = new SaleorAsyncWebhook(options); +``` + +For `SaleorSyncWebhook` it will be similar. Create e.g. `order-calculate-taxes.ts` page and create a new instance: + +```typescript +import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next"; + +/** + * To be type-safe, define payload from API. This should be imported from generated GraphQL code + */ +type CalculateTaxedPayload = { + // taxes payload from subscription +}; + +export const orderCalculateTaxesWebhook = new SaleorSyncWebhook(options); +``` + +### Configuring Webhook instance + +`options` in constructor must be specified. Here are all options: + +```typescript +type Options = { + /** + * Additional webhook name, optional. + */ + name?: string; + /** + * Path to webhook. Should represent relative path from base app URL. In Next.js it will start with `api/`, e.g. `api/webhooks/order-created`. + */ + webhookPath: string; + /** + * Valid Async or Sync webhook. Constructor is statically typed so only valid Sync/Async webhooks will be allowed + */ + event: Event; + /** + * Should event be active after installation, enabled by default + */ + isActive?: boolean; + /** + * APL instance - see docs/apl/md + */ + apl: APL; + /** + * Optional callback that allows to inject custom error handling and take control of the response + */ + onError?(error: WebhookError | Error, req: NextApiRequest, res: NextApiResponse): void; + /** + * Optional callback that allows to format error message. Useful to control how much details should be returned in the response + */ + formatErrorResponse?( + error: WebhookError | Error, + req: NextApiRequest, + res: NextApiResponse + ): Promise<{ + code: number; + body: object | string; + }>; + /** + * Required subscription query. Can be raw GraphQL string or the instance of query wrapped in `gql` tags + */ + query: string | ASTNode; +}; +``` + +#### Configuration examples + +##### SaleorAsyncWebhook configuration example + +```typescript +// pages/api/webhooks/order-created.ts + +/** + * To be type-safe, define payload from API. This should be imported from generated GraphQL code + */ +type OrderPayload = { + id: string; +}; + +export const orderCreatedWebhook = new SaleorAsyncWebhook({ + name: "Order Created", + webhookPath: "api/webhooks/order-created", + event: "ORDER_CREATED", + isActive: true, + apl: require("../lib/apl"), + query: ` + subscription { + event { + ... on OrderCreated { + order { + id + } + } + } + } + `, + onError(error: WebhookError | Error) { + // Can be used to e.g. trace errors + sentry.captureError(error); + }, + async formatErrorResponse( + error: WebhookError | Error, + req: NextApiRequest, + res: NextApiResponse + ) { + return { + code: 400, + body: "My custom response", + }; + }, +}); +``` + +##### SyncAsyncWebhook configuration example + +```typescript +// pages/api/webhooks/order-created.ts + +/** + * To be type safe, define payload from API. This should be imported from generated graphQL code + */ +type Payload = { + taxBase: { + currency: string; + }; +}; + +export const orderCalculateTaxesWebhook = new SaleorAsyncWebhook({ + name: "Order Calculate Taxes", + webhookPath: "api/webhooks/order-created", + event: "ORDER_CALCULATE_TAXES", + isActive: true, + apl: require("../lib/apl"), + query: ` + subscription { + event { + ... on CalculateTaxes { + taxBase { + currency + } + } + } + } + `, + onError(error: WebhookError | Error) { + sentry.captureError(error); + }, + async formatErrorResponse( + error: WebhookError | Error, + req: NextApiRequest, + res: NextApiResponse + ) { + return { + code: 400, + body: "My custom response", + }; + }, +}); +``` + +### Resources + +- Check available events [here](https://docs.saleor.io/docs/3.x/developer/extending/apps/asynchronous-webhooks#available-webhook-events) +- [Read more about APLs](./apl.md) +- [Subscription query documentation](https://docs.saleor.io/docs/3.x/developer/extending/apps/subscription-webhook-payloads) + +### Extending app manifest + +Webhooks are created in Saleor when the App is installed. Saleor uses [AppManifest](https://docs.saleor.io/docs/3.x/developer/extending/apps/manifest) to get information about webhooks to create. +`SaleorSyncWebhook` & `SaleorAsyncWebhook` utility can generate this manifest: + +```typescript +// pages/api/manifest + +import { createManifestHandler } from "@saleor/app-sdk/handlers/next"; + +import { orderCreatedWebhook } from "./order-created.ts"; + +export default createManifestHandler({ + manifestFactory({ appBaseUrl }) { + return { + /** + * Add one or more webhook manifests. + */ + webhooks: [orderCreatedWebhook.getWebhookManifest(appBaseUrl)], + // ...rest of your App's manifest + }; + }, +}); +``` + +Now, try to read your manifest, in default Next.js config it will be `GET localhost:3000/api/manifest`. You should see webhook configuration as part of manifest response. + +### Creating webhook domain logic + +Now, let's create a handler that will process webhook data. Let's back to handler file `pages/api/webhooks/order-created.ts`. + +```typescript +type OrderPayload = { + id: string; +}; + +export const orderCreatedWebhook = new SaleorAsyncWebhook({ + // ... your configuration +}); + +/** + * Handler has to be a default export so the Next.js will be able to use it + */ +export default orderCreatedWebhook.createHandler((req, res, context) => { + const { baseUrl, event, payload, authData } = context; + + console.log(payload.id); // type is inferred + + // Perform some domain logic + + // End with status 200 + return res.status(200).end(); +}); +``` + +### Typed sync webhook response + +Sync webhooks need to return response to Saleor, so operation can be completed. To achieve that, `SaleorAsyncWebhook` injects additional context field `buildResponse`. +It infers even from constructor and provides typed factory: + +```typescript +const webhook = new SaleorAsyncWebhook({ event: "ORDER_CALCULATE_TAXES" /* ... rest of config */ }); + +orderCreatedWebhook.createHandler((req, res, context) => { + return res.status(200).send( + context.buildResponse({ + // Fields are typed here + }) + ); +}); +``` + +### query vs subscriptionQueryAst + +Subscription query can be specified using plain string or as `ASTNode` object created by `gql` tag. + +If your project does not use any code generation for GraphQL operations, use the string. In case you are using [GraphQL Code Generator](https://the-guild.dev/graphql/codegen), which we highly recommend, you should pass a subscription as GraphQL ASTNode: + +```typescript +/** + * Subscription query, you can define it in the `.ts` file. If you write operations in separate `.graphql` files, codegen will also export them in the generated file. + */ +export const ExampleProductUpdatedSubscription = gql` + ${ProductUpdatedWebhookPayload} + subscription ExampleProductUpdated { + event { + fragment + ProductUpdatedWebhookPayload + on + ProductUpdated { + product { + id + name + } + } + } + } +`; + +export const productUpdatedWebhook = new SaleorAsyncWebhook({ + name: "Example product updated webhook", + webhookPath: "api/webhooks/saleor/product-updated", + event: "PRODUCT_UPDATED", + apl: saleorApp.apl, + query: ExampleProductUpdatedSubscription, // Or use plain string +}); +``` diff --git a/package.json b/package.json index 402a4df..94122f3 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "react-dom": "18.2.0", "tsm": "^2.2.2", "tsup": "^6.2.3", - "typescript": "^4.8.2", + "typescript": "^4.9.5", "vi-fetch": "^0.8.0", "vite": "^4.0.4", "vitest": "^0.28.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f91d3c8..91520be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,7 +40,7 @@ specifiers: retes: ^0.33.0 tsm: ^2.2.2 tsup: ^6.2.3 - typescript: ^4.8.2 + typescript: ^4.9.5 uuid: ^8.3.2 vi-fetch: ^0.8.0 vite: ^4.0.4 @@ -65,8 +65,8 @@ devDependencies: '@types/react': 18.0.21 '@types/react-dom': 18.0.6 '@types/uuid': 8.3.4 - '@typescript-eslint/eslint-plugin': 5.36.1_lbwfnm54o3pmr3ypeqp3btnera - '@typescript-eslint/parser': 5.36.1_yqf6kl63nyoq5megxukfnom5rm + '@typescript-eslint/eslint-plugin': 5.36.1_fl7tjqljox3oqt6nqaer2gdi5a + '@typescript-eslint/parser': 5.36.1_yjwicu3lrm7zijfb2ermzlxu3e '@vitejs/plugin-react': 3.0.1_vite@4.0.4 '@vitest/coverage-c8': 0.27.2_jsdom@20.0.3 clean-publish: 4.0.1 @@ -88,8 +88,8 @@ devDependencies: react: 18.2.0 react-dom: 18.2.0_react@18.2.0 tsm: 2.2.2 - tsup: 6.2.3_typescript@4.8.2 - typescript: 4.8.2 + tsup: 6.2.3_typescript@4.9.5 + typescript: 4.9.5 vi-fetch: 0.8.0 vite: 4.0.4_@types+node@18.7.15 vitest: 0.28.1_jsdom@20.0.3 @@ -1145,7 +1145,7 @@ packages: resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==} dev: true - /@typescript-eslint/eslint-plugin/5.36.1_lbwfnm54o3pmr3ypeqp3btnera: + /@typescript-eslint/eslint-plugin/5.36.1_fl7tjqljox3oqt6nqaer2gdi5a: resolution: {integrity: sha512-iC40UK8q1tMepSDwiLbTbMXKDxzNy+4TfPWgIL661Ym0sD42vRcQU93IsZIrmi+x292DBr60UI/gSwfdVYexCA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -1156,23 +1156,23 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/parser': 5.36.1_yqf6kl63nyoq5megxukfnom5rm + '@typescript-eslint/parser': 5.36.1_yjwicu3lrm7zijfb2ermzlxu3e '@typescript-eslint/scope-manager': 5.36.1 - '@typescript-eslint/type-utils': 5.36.1_yqf6kl63nyoq5megxukfnom5rm - '@typescript-eslint/utils': 5.36.1_yqf6kl63nyoq5megxukfnom5rm + '@typescript-eslint/type-utils': 5.36.1_yjwicu3lrm7zijfb2ermzlxu3e + '@typescript-eslint/utils': 5.36.1_yjwicu3lrm7zijfb2ermzlxu3e debug: 4.3.4 eslint: 8.23.0 functional-red-black-tree: 1.0.1 ignore: 5.2.0 regexpp: 3.2.0 semver: 7.3.7 - tsutils: 3.21.0_typescript@4.8.2 - typescript: 4.8.2 + tsutils: 3.21.0_typescript@4.9.5 + typescript: 4.9.5 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/parser/5.36.1_yqf6kl63nyoq5megxukfnom5rm: + /@typescript-eslint/parser/5.36.1_yjwicu3lrm7zijfb2ermzlxu3e: resolution: {integrity: sha512-/IsgNGOkBi7CuDfUbwt1eOqUXF9WGVBW9dwEe1pi+L32XrTsZIgmDFIi2RxjzsvB/8i+MIf5JIoTEH8LOZ368A==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -1184,10 +1184,10 @@ packages: dependencies: '@typescript-eslint/scope-manager': 5.36.1 '@typescript-eslint/types': 5.36.1 - '@typescript-eslint/typescript-estree': 5.36.1_typescript@4.8.2 + '@typescript-eslint/typescript-estree': 5.36.1_typescript@4.9.5 debug: 4.3.4 eslint: 8.23.0 - typescript: 4.8.2 + typescript: 4.9.5 transitivePeerDependencies: - supports-color dev: true @@ -1200,7 +1200,7 @@ packages: '@typescript-eslint/visitor-keys': 5.36.1 dev: true - /@typescript-eslint/type-utils/5.36.1_yqf6kl63nyoq5megxukfnom5rm: + /@typescript-eslint/type-utils/5.36.1_yjwicu3lrm7zijfb2ermzlxu3e: resolution: {integrity: sha512-xfZhfmoQT6m3lmlqDvDzv9TiCYdw22cdj06xY0obSznBsT///GK5IEZQdGliXpAOaRL34o8phEvXzEo/VJx13Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -1210,12 +1210,12 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/typescript-estree': 5.36.1_typescript@4.8.2 - '@typescript-eslint/utils': 5.36.1_yqf6kl63nyoq5megxukfnom5rm + '@typescript-eslint/typescript-estree': 5.36.1_typescript@4.9.5 + '@typescript-eslint/utils': 5.36.1_yjwicu3lrm7zijfb2ermzlxu3e debug: 4.3.4 eslint: 8.23.0 - tsutils: 3.21.0_typescript@4.8.2 - typescript: 4.8.2 + tsutils: 3.21.0_typescript@4.9.5 + typescript: 4.9.5 transitivePeerDependencies: - supports-color dev: true @@ -1225,7 +1225,7 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true - /@typescript-eslint/typescript-estree/5.36.1_typescript@4.8.2: + /@typescript-eslint/typescript-estree/5.36.1_typescript@4.9.5: resolution: {integrity: sha512-ih7V52zvHdiX6WcPjsOdmADhYMDN15SylWRZrT2OMy80wzKbc79n8wFW0xpWpU0x3VpBz/oDgTm2xwDAnFTl+g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -1240,13 +1240,13 @@ packages: globby: 11.1.0 is-glob: 4.0.3 semver: 7.3.7 - tsutils: 3.21.0_typescript@4.8.2 - typescript: 4.8.2 + tsutils: 3.21.0_typescript@4.9.5 + typescript: 4.9.5 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/utils/5.36.1_yqf6kl63nyoq5megxukfnom5rm: + /@typescript-eslint/utils/5.36.1_yjwicu3lrm7zijfb2ermzlxu3e: resolution: {integrity: sha512-lNj4FtTiXm5c+u0pUehozaUWhh7UYKnwryku0nxJlYUEWetyG92uw2pr+2Iy4M/u0ONMKzfrx7AsGBTCzORmIg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -1255,7 +1255,7 @@ packages: '@types/json-schema': 7.0.11 '@typescript-eslint/scope-manager': 5.36.1 '@typescript-eslint/types': 5.36.1 - '@typescript-eslint/typescript-estree': 5.36.1_typescript@4.8.2 + '@typescript-eslint/typescript-estree': 5.36.1_typescript@4.9.5 eslint: 8.23.0 eslint-scope: 5.1.1 eslint-utils: 3.0.0_eslint@8.23.0 @@ -2720,7 +2720,7 @@ packages: eslint-import-resolver-webpack: optional: true dependencies: - '@typescript-eslint/parser': 5.36.1_yqf6kl63nyoq5megxukfnom5rm + '@typescript-eslint/parser': 5.36.1_yjwicu3lrm7zijfb2ermzlxu3e debug: 3.2.7 eslint: 8.23.0 eslint-import-resolver-node: 0.3.6 @@ -2739,7 +2739,7 @@ packages: '@typescript-eslint/parser': optional: true dependencies: - '@typescript-eslint/parser': 5.36.1_yqf6kl63nyoq5megxukfnom5rm + '@typescript-eslint/parser': 5.36.1_yjwicu3lrm7zijfb2ermzlxu3e array-includes: 3.1.5 array.prototype.flat: 1.3.0 debug: 2.6.9 @@ -5306,7 +5306,7 @@ packages: esbuild: 0.14.54 dev: true - /tsup/6.2.3_typescript@4.8.2: + /tsup/6.2.3_typescript@4.9.5: resolution: {integrity: sha512-J5Pu2Dx0E1wlpIEsVFv9ryzP1pZ1OYsJ2cBHZ7GrKteytNdzaSz5hmLX7/nAxtypq+jVkVvA79d7S83ETgHQ5w==} engines: {node: '>=14'} hasBin: true @@ -5336,20 +5336,20 @@ packages: source-map: 0.8.0-beta.0 sucrase: 3.25.0 tree-kill: 1.2.2 - typescript: 4.8.2 + typescript: 4.9.5 transitivePeerDependencies: - supports-color - ts-node dev: true - /tsutils/3.21.0_typescript@4.8.2: + /tsutils/3.21.0_typescript@4.9.5: resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} engines: {node: '>= 6'} peerDependencies: typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' dependencies: tslib: 1.14.1 - typescript: 4.8.2 + typescript: 4.9.5 dev: true /tty-table/4.1.6: @@ -5418,8 +5418,8 @@ packages: mime-types: 2.1.35 dev: true - /typescript/4.8.2: - resolution: {integrity: sha512-C0I1UsrrDHo2fYI5oaCGbSejwX4ch+9Y5jTQELvovfmFkK3HHSZJB8MSJcWLmCUBzQBchCrZ9rMRV6GuNrvGtw==} + /typescript/4.9.5: + resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} engines: {node: '>=4.2.0'} hasBin: true dev: true diff --git a/src/handlers/next/create-app-register-handler.test.ts b/src/handlers/next/create-app-register-handler.test.ts index c49de12..709ed0f 100644 --- a/src/handlers/next/create-app-register-handler.test.ts +++ b/src/handlers/next/create-app-register-handler.test.ts @@ -1,7 +1,8 @@ import { createMocks } from "node-mocks-http"; -import { describe, expect, it, Mock, vi } from "vitest"; +import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; import { APL, AuthData } from "../../APL"; +import { MockAPL } from "../../test-utils/mock-apl"; import { createAppRegisterHandler } from "./create-app-register-handler"; const mockJwksValue = "{}"; @@ -15,20 +16,13 @@ vi.mock("../../fetch-remote-jwks", () => ({ fetchRemoteJwks: vi.fn().mockResolvedValue("{}"), // can't use var reference, due to hoisting })); -const mockApl: APL = { - get: vi.fn(), - set: vi.fn(), - delete: vi.fn(), - getAll: vi.fn(), - isReady: vi.fn().mockImplementation(async () => ({ - ready: true, - })), - isConfigured: vi.fn().mockImplementation(async () => ({ - configured: true, - })), -}; - describe("create-app-register-handler", () => { + let mockApl: APL; + + beforeEach(() => { + mockApl = new MockAPL(); + }); + it("Sets auth data for correct request", async () => { const { res, req } = createMocks({ /** diff --git a/src/handlers/next/create-protected-handler.ts b/src/handlers/next/create-protected-handler.ts index c17fb22..9134f7a 100644 --- a/src/handlers/next/create-protected-handler.ts +++ b/src/handlers/next/create-protected-handler.ts @@ -3,12 +3,12 @@ import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; import { APL } from "../../APL"; import { createDebug } from "../../debug"; import { AppPermission } from "../../types"; -import { ProtectedHandlerContext } from "./process-async-saleor-webhook"; import { processSaleorProtectedHandler, ProtectedHandlerError, SaleorProtectedHandlerError, } from "./process-protected-handler"; +import { ProtectedHandlerContext } from "./saleor-webhooks/process-saleor-webhook"; const debug = createDebug("ProtectedHandler"); diff --git a/src/handlers/next/index.ts b/src/handlers/next/index.ts index 862f1d2..6a33554 100644 --- a/src/handlers/next/index.ts +++ b/src/handlers/next/index.ts @@ -2,4 +2,6 @@ export * from "./create-app-register-handler"; export * from "./create-manifest-handler"; export * from "./create-protected-handler"; export * from "./process-protected-handler"; -export * from "./saleor-async-webhook"; +export * from "./saleor-webhooks/saleor-async-webhook"; +export * from "./saleor-webhooks/saleor-sync-webhook"; +export * from "./saleor-webhooks/sync-webhook-response-builder"; diff --git a/src/handlers/next/process-protected-handler.test.ts b/src/handlers/next/process-protected-handler.test.ts index 13bc966..261ccf1 100644 --- a/src/handlers/next/process-protected-handler.test.ts +++ b/src/handlers/next/process-protected-handler.test.ts @@ -2,8 +2,8 @@ import { NextApiRequest } from "next/types"; import { createMocks } from "node-mocks-http"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { APL } from "../../APL"; import { getAppId } from "../../get-app-id"; +import { MockAPL } from "../../test-utils/mock-apl"; import { verifyJWT } from "../../verify-jwt"; import { processSaleorProtectedHandler } from "./process-protected-handler"; @@ -25,32 +25,18 @@ vi.mock("./../../verify-jwt", () => ({ describe("processSaleorProtectedHandler", () => { let mockRequest: NextApiRequest; - const mockAPL: APL = { - get: async (saleorApiUrl: string) => - saleorApiUrl === "https://example.com/graphql/" - ? { - domain: "example.com", - token: "mock-token", - saleorApiUrl: "https://example.com/graphql/", - appId: "42", - jwks: "{}", - } - : undefined, - set: vi.fn(), - delete: vi.fn(), - getAll: vi.fn(), - isReady: vi.fn(), - isConfigured: vi.fn(), - }; + let mockAPL: MockAPL; beforeEach(() => { + mockAPL = new MockAPL(); + // Create request method which passes all the tests const { req } = createMocks({ headers: { host: "some-saleor-host.cloud", "x-forwarded-proto": "https", - "saleor-domain": "example.com", - "saleor-api-url": "https://example.com/graphql/", + "saleor-domain": mockAPL.workingSaleorDomain, + "saleor-api-url": mockAPL.workingSaleorApiUrl, "saleor-event": "product_updated", "saleor-signature": "mocked_signature", "authorization-bearer": validToken, @@ -70,11 +56,11 @@ describe("processSaleorProtectedHandler", () => { expect(await processSaleorProtectedHandler({ apl: mockAPL, req: mockRequest })).toStrictEqual({ authData: { - domain: "example.com", - token: "mock-token", - saleorApiUrl: "https://example.com/graphql/", - appId: "42", - jwks: "{}", + domain: mockAPL.workingSaleorDomain, + token: mockAPL.mockToken, + saleorApiUrl: mockAPL.workingSaleorApiUrl, + appId: mockAPL.mockAppId, + jwks: mockAPL.mockJwks, }, baseUrl: "https://some-saleor-host.cloud", }); diff --git a/src/handlers/next/process-async-saleor-webhook.test.ts b/src/handlers/next/saleor-webhooks/process-saleor-webhook.test.ts similarity index 72% rename from src/handlers/next/process-async-saleor-webhook.test.ts rename to src/handlers/next/saleor-webhooks/process-saleor-webhook.test.ts index 4de9d80..ead2d3d 100644 --- a/src/handlers/next/process-async-saleor-webhook.test.ts +++ b/src/handlers/next/saleor-webhooks/process-saleor-webhook.test.ts @@ -3,10 +3,10 @@ import { createMocks } from "node-mocks-http"; import rawBody from "raw-body"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { APL } from "../../APL"; -import { processAsyncSaleorWebhook } from "./process-async-saleor-webhook"; +import { MockAPL } from "../../../test-utils/mock-apl"; +import { processSaleorWebhook } from "./process-saleor-webhook"; -vi.mock("./../../verify-signature", () => ({ +vi.mock("../../../verify-signature", () => ({ // eslint-disable-next-line @typescript-eslint/no-unused-vars verifySignature: vi.fn((domain, signature) => { if (signature !== "mocked_signature") { @@ -32,23 +32,7 @@ vi.mock("raw-body", () => ({ describe("processAsyncSaleorWebhook", () => { let mockRequest: NextApiRequest; - const mockAPL: APL = { - get: async (saleorApiUrl: string) => - saleorApiUrl === "https://example.com/graphql/" - ? { - domain: "example.com", - token: "mock-token", - saleorApiUrl: "https://example.com/graphql/", - appId: "42", - jwks: "{}", - } - : undefined, - set: vi.fn(), - delete: vi.fn(), - getAll: vi.fn(), - isReady: vi.fn(), - isConfigured: vi.fn(), - }; + const mockAPL = new MockAPL(); beforeEach(() => { // Create request method which passes all the tests @@ -56,8 +40,8 @@ describe("processAsyncSaleorWebhook", () => { headers: { host: "some-saleor-host.cloud", "x-forwarded-proto": "https", - "saleor-domain": "example.com", - "saleor-api-url": "https://example.com/graphql/", + "saleor-domain": mockAPL.workingSaleorDomain, + "saleor-api-url": mockAPL.workingSaleorApiUrl, "saleor-event": "product_updated", "saleor-signature": "mocked_signature", "content-length": "0", // is ignored by mocked raw-body @@ -68,19 +52,20 @@ describe("processAsyncSaleorWebhook", () => { mockRequest = req; }); - it("Process valid request", async () => { - await processAsyncSaleorWebhook({ - req: mockRequest, - apl: mockAPL, - allowedEvent: "PRODUCT_UPDATED", - }); - }); + it("Process valid request", async () => + expect(() => + processSaleorWebhook({ + req: mockRequest, + apl: mockAPL, + allowedEvent: "PRODUCT_UPDATED", + }) + ).not.toThrow()); it("Throw error on non-POST request method", async () => { mockRequest.method = "GET"; await expect( - processAsyncSaleorWebhook({ req: mockRequest, apl: mockAPL, allowedEvent: "PRODUCT_UPDATED" }) + processSaleorWebhook({ req: mockRequest, apl: mockAPL, allowedEvent: "PRODUCT_UPDATED" }) ).rejects.toThrow("Wrong request method"); }); @@ -88,7 +73,7 @@ describe("processAsyncSaleorWebhook", () => { delete mockRequest.headers["saleor-api-url"]; await expect( - processAsyncSaleorWebhook({ req: mockRequest, apl: mockAPL, allowedEvent: "PRODUCT_UPDATED" }) + processSaleorWebhook({ req: mockRequest, apl: mockAPL, allowedEvent: "PRODUCT_UPDATED" }) ).rejects.toThrow("Missing saleor-api-url header"); }); @@ -96,7 +81,7 @@ describe("processAsyncSaleorWebhook", () => { delete mockRequest.headers["saleor-event"]; await expect( - processAsyncSaleorWebhook({ + processSaleorWebhook({ req: mockRequest, apl: mockAPL, allowedEvent: "PRODUCT_UPDATED", @@ -107,14 +92,14 @@ describe("processAsyncSaleorWebhook", () => { it("Throw error on mismatched event header", async () => { mockRequest.headers["saleor-event"] = "different_event"; await expect( - processAsyncSaleorWebhook({ req: mockRequest, apl: mockAPL, allowedEvent: "PRODUCT_UPDATED" }) + processSaleorWebhook({ req: mockRequest, apl: mockAPL, allowedEvent: "PRODUCT_UPDATED" }) ).rejects.toThrow("Wrong incoming request event: different_event. Expected: product_updated"); }); it("Throw error on missing signature header", async () => { delete mockRequest.headers["saleor-signature"]; await expect( - processAsyncSaleorWebhook({ + processSaleorWebhook({ req: mockRequest, apl: mockAPL, allowedEvent: "PRODUCT_UPDATED", @@ -128,7 +113,7 @@ describe("processAsyncSaleorWebhook", () => { }); await expect( - processAsyncSaleorWebhook({ + processSaleorWebhook({ req: mockRequest, apl: mockAPL, allowedEvent: "PRODUCT_UPDATED", @@ -139,7 +124,7 @@ describe("processAsyncSaleorWebhook", () => { it("Throw error on not registered app", async () => { mockRequest.headers["saleor-api-url"] = "https://not-registered.example.com/graphql/"; await expect( - processAsyncSaleorWebhook({ + processSaleorWebhook({ req: mockRequest, apl: mockAPL, allowedEvent: "PRODUCT_UPDATED", @@ -151,11 +136,13 @@ describe("processAsyncSaleorWebhook", () => { it("Throw error on wrong signature", async () => { mockRequest.headers["saleor-signature"] = "wrong_signature"; + vi.mock("./../../fetch-remote-jwks", () => ({ - fetchRemoteJwks: vi.fn(() => "wrong_signature"), + fetchRemoteJwks: vi.fn(async () => "wrong_signature"), })); - await expect( - processAsyncSaleorWebhook({ + + return expect( + processSaleorWebhook({ req: mockRequest, apl: mockAPL, allowedEvent: "PRODUCT_UPDATED", diff --git a/src/handlers/next/process-async-saleor-webhook.ts b/src/handlers/next/saleor-webhooks/process-saleor-webhook.ts similarity index 81% rename from src/handlers/next/process-async-saleor-webhook.ts rename to src/handlers/next/saleor-webhooks/process-saleor-webhook.ts index c72beda..2e36043 100644 --- a/src/handlers/next/process-async-saleor-webhook.ts +++ b/src/handlers/next/saleor-webhooks/process-saleor-webhook.ts @@ -1,14 +1,14 @@ import { NextApiRequest } from "next"; import getRawBody from "raw-body"; -import { APL } from "../../APL"; -import { AuthData } from "../../APL/apl"; -import { createDebug } from "../../debug"; -import { fetchRemoteJwks } from "../../fetch-remote-jwks"; -import { getBaseUrl, getSaleorHeaders } from "../../headers"; -import { verifySignatureWithJwks } from "../../verify-signature"; +import { APL } from "../../../APL"; +import { AuthData } from "../../../APL/apl"; +import { createDebug } from "../../../debug"; +import { fetchRemoteJwks } from "../../../fetch-remote-jwks"; +import { getBaseUrl, getSaleorHeaders } from "../../../headers"; +import { verifySignatureWithJwks } from "../../../verify-signature"; -const debug = createDebug("processAsyncWebhook"); +const debug = createDebug("processSaleorWebhook"); export type SaleorWebhookError = | "OTHER" @@ -56,7 +56,7 @@ interface ProcessSaleorWebhookArgs { allowedEvent: string; } -type ProcessAsyncSaleorWebhook = ( +type ProcessSaleorWebhook = ( props: ProcessSaleorWebhookArgs ) => Promise>; @@ -66,16 +66,18 @@ type ProcessAsyncSaleorWebhook = ( * * @returns WebhookContext */ -export const processAsyncSaleorWebhook: ProcessAsyncSaleorWebhook = async ({ +export const processSaleorWebhook: ProcessSaleorWebhook = async ({ req, apl, allowedEvent, }: ProcessSaleorWebhookArgs): Promise> => { debug("Request processing started"); + if (req.method !== "POST") { debug("Wrong HTTP method"); throw new WebhookError("Wrong request method, only POST allowed", "WRONG_METHOD"); } + const { event, signature, saleorApiUrl } = getSaleorHeaders(req.headers); const baseUrl = getBaseUrl(req.headers); @@ -95,8 +97,10 @@ export const processAsyncSaleorWebhook: ProcessAsyncSaleorWebhook = async ({ } const expected = allowedEvent.toLowerCase(); + if (event !== expected) { debug(`Wrong incoming request event: ${event}. Expected: ${expected}`); + throw new WebhookError( `Wrong incoming request event: ${event}. Expected: ${expected}`, "WRONG_EVENT" @@ -105,6 +109,7 @@ export const processAsyncSaleorWebhook: ProcessAsyncSaleorWebhook = async ({ if (!signature) { debug("No signature"); + throw new WebhookError("Missing saleor-signature header", "MISSING_SIGNATURE_HEADER"); } @@ -116,41 +121,65 @@ export const processAsyncSaleorWebhook: ProcessAsyncSaleorWebhook = async ({ ).toString(); if (!rawBody) { debug("Missing request body"); + throw new WebhookError("Missing request body", "MISSING_REQUEST_BODY"); } let parsedBody: unknown; + try { parsedBody = JSON.parse(rawBody); } catch { debug("Request body cannot be parsed"); + throw new WebhookError("Request body can't be parsed", "CANT_BE_PARSED"); } - // Check if domain is installed in the app + /** + * Verify if the app is properly installed for given Saleor API URL + */ const authData = await apl.get(saleorApiUrl); if (!authData) { debug("APL didn't found auth data for %s", saleorApiUrl); + throw new WebhookError( `Can't find auth data for ${saleorApiUrl}. Please register the application`, "NOT_REGISTERED" ); } - // Payload signature check + /** + * Verify payload signature + * + * TODO: Add test for repeat verification scenario + */ try { + debug("Will verify signature with JWKS saved in AuthData"); + await verifySignatureWithJwks(authData.jwks, signature, rawBody); } catch { debug("Request signature check failed. Refresh the JWKS cache and check again"); - const newJwks = await fetchRemoteJwks(authData.saleorApiUrl); + + const newJwks = await fetchRemoteJwks(authData.saleorApiUrl).catch((e) => { + debug(e); + + throw new WebhookError("Fetching remote JWKS failed", "SIGNATURE_VERIFICATION_FAILED"); + }); + + debug("Fetched refreshed JWKS"); + try { debug("Second attempt to validate the signature JWKS, using fresh tokens from the API"); + await verifySignatureWithJwks(newJwks, signature, rawBody); + debug("Verification successful - update JWKS in the AuthData"); + await apl.set({ ...authData, jwks: newJwks }); } catch { debug("Second attempt also ended with validation error. Reject the webhook"); + throw new WebhookError("Request signature check failed", "SIGNATURE_VERIFICATION_FAILED"); } } diff --git a/src/handlers/next/saleor-async-webhook.test.ts b/src/handlers/next/saleor-webhooks/saleor-async-webhook.test.ts similarity index 72% rename from src/handlers/next/saleor-async-webhook.test.ts rename to src/handlers/next/saleor-webhooks/saleor-async-webhook.test.ts index 961d33b..cc6df75 100644 --- a/src/handlers/next/saleor-async-webhook.test.ts +++ b/src/handlers/next/saleor-webhooks/saleor-async-webhook.test.ts @@ -1,71 +1,47 @@ -import { ASTNode } from "graphql"; import { createMocks } from "node-mocks-http"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { APL } from "../../APL"; -import { processAsyncSaleorWebhook } from "./process-async-saleor-webhook"; -import { NextWebhookApiHandler, SaleorAsyncWebhook } from "./saleor-async-webhook"; +import { MockAPL } from "../../../test-utils/mock-apl"; +import { AsyncWebhookEventType } from "../../../types"; +import { processSaleorWebhook } from "./process-saleor-webhook"; +import { SaleorAsyncWebhook } from "./saleor-async-webhook"; +import { NextWebhookApiHandler, WebhookConfig } from "./saleor-webhook"; const webhookPath = "api/webhooks/product-updated"; const baseUrl = "http://example.com"; describe("SaleorAsyncWebhook", () => { - const mockAPL: APL = { - get: async (domain: string) => - domain === "example.com" - ? { - domain: "example.com", - token: "mock-token", - jwks: "", - saleorApiUrl: "https://example.com/graphql/", - appId: "12345", - } - : undefined, - set: vi.fn(), - delete: vi.fn(), - getAll: vi.fn(), - isReady: vi.fn(), - isConfigured: vi.fn(), - }; + const mockAPL = new MockAPL(); afterEach(async () => { vi.restoreAllMocks(); }); - const validAsyncWebhookConfiguration = { + const validAsyncWebhookConfiguration: WebhookConfig = { apl: mockAPL, - asyncEvent: "PRODUCT_UPDATED", + event: "PRODUCT_UPDATED", webhookPath, query: "subscription { event { ... on ProductUpdated { product { id }}}}", } as const; const saleorAsyncWebhook = new SaleorAsyncWebhook(validAsyncWebhookConfiguration); - it("throw CONFIGURATION_ERROR if query and subscriptionQueryAst are both absent", async () => { + it("constructor passes if query is provided", async () => { expect(() => { // eslint-disable-next-line no-new new SaleorAsyncWebhook({ ...validAsyncWebhookConfiguration, - // @ts-ignore: We make type error for test purpose - query: undefined, - subscriptionQueryAst: undefined, - }); - }).toThrowError(); - }); - - it("constructor passes if subscriptionQueryAst is provided", async () => { - expect(() => { - // eslint-disable-next-line no-new - new SaleorAsyncWebhook({ - ...validAsyncWebhookConfiguration, - query: undefined, - subscriptionQueryAst: {} as ASTNode, + query: "subscription { event { ... on ProductUpdated { product { id }}}}", }); }).not.toThrowError(); }); it("targetUrl should return full path to the webhook route based on given baseUrl", async () => { - expect(saleorAsyncWebhook.getTargetUrl(baseUrl)).toBe(`${baseUrl}/${webhookPath}`); + expect(saleorAsyncWebhook.getWebhookManifest(baseUrl)).toEqual( + expect.objectContaining({ + targetUrl: `${baseUrl}/${webhookPath}`, + }) + ); }); it("getWebhookManifest should return a valid manifest", async () => { @@ -80,9 +56,9 @@ describe("SaleorAsyncWebhook", () => { it("Test createHandler which return success", async () => { // prepare mocked context returned by mocked process function - vi.mock("./process-async-saleor-webhook"); + vi.mock("./process-saleor-webhook"); - vi.mocked(processAsyncSaleorWebhook).mockImplementationOnce(async () => ({ + vi.mocked(processSaleorWebhook).mockImplementationOnce(async () => ({ baseUrl: "example.com", event: "product_updated", payload: { data: "test_payload" }, @@ -109,6 +85,7 @@ describe("SaleorAsyncWebhook", () => { const { req, res } = createMocks(); const wrappedHandler = saleorAsyncWebhook.createHandler(testHandler); await wrappedHandler(req, res); + expect(res.statusCode).toBe(200); // Check if test handler was used by the wrapper @@ -129,9 +106,9 @@ describe("SaleorAsyncWebhook", () => { }); // prepare mocked context returned by mocked process function - vi.mock("./process-async-saleor-webhook"); + vi.mock("./process-saleor-webhook"); - vi.mocked(processAsyncSaleorWebhook).mockImplementationOnce(async () => { + vi.mocked(processSaleorWebhook).mockImplementationOnce(async () => { /** * This mock should throw WebhookError, but there was TypeError related to constructor of extended class. * Try "throw new WebhookError()" to check it. diff --git a/src/handlers/next/saleor-webhooks/saleor-async-webhook.ts b/src/handlers/next/saleor-webhooks/saleor-async-webhook.ts new file mode 100644 index 0000000..4a4ba5a --- /dev/null +++ b/src/handlers/next/saleor-webhooks/saleor-async-webhook.ts @@ -0,0 +1,30 @@ +import { NextApiHandler } from "next"; + +import { AsyncWebhookEventType } from "../../../types"; +import { NextWebhookApiHandler, SaleorWebhook, WebhookConfig } from "./saleor-webhook"; + +export class SaleorAsyncWebhook extends SaleorWebhook { + readonly event: AsyncWebhookEventType; + + protected readonly eventType = "async" as const; + + constructor( + configuration: WebhookConfig & { + /** + * @deprecated - use `event` instead. Will be removed in 0.35.0 + */ + asyncEvent?: AsyncWebhookEventType; + } + ) { + super({ + ...configuration, + event: configuration.event ?? configuration.asyncEvent, + }); + + this.event = configuration.event ?? configuration.asyncEvent; + } + + createHandler(handlerFn: NextWebhookApiHandler): NextApiHandler { + return super.createHandler(handlerFn); + } +} diff --git a/src/handlers/next/saleor-webhooks/saleor-sync-webhook.test.ts b/src/handlers/next/saleor-webhooks/saleor-sync-webhook.test.ts new file mode 100644 index 0000000..fb13431 --- /dev/null +++ b/src/handlers/next/saleor-webhooks/saleor-sync-webhook.test.ts @@ -0,0 +1,80 @@ +import { createMocks } from "node-mocks-http"; +import { describe, expect, it, vi } from "vitest"; + +import { MockAPL } from "../../../test-utils/mock-apl"; +import { processSaleorWebhook } from "./process-saleor-webhook"; +import { SaleorSyncWebhook } from "./saleor-sync-webhook"; + +describe("SaleorSyncWebhook", () => { + const mockApl = new MockAPL(); + + it("Provides type-safe response builder in the context", async () => { + vi.mock("./process-saleor-webhook"); + + vi.mocked(processSaleorWebhook).mockImplementationOnce(async () => ({ + baseUrl: "example.com", + event: "CHECKOUT_CALCULATE_TAXES", + payload: { data: "test_payload" }, + authData: { + domain: mockApl.workingSaleorDomain, + token: mockApl.mockToken, + jwks: mockApl.mockJwks, + saleorApiUrl: mockApl.workingSaleorApiUrl, + appId: mockApl.mockAppId, + }, + })); + + const { req, res } = createMocks({ + method: "POST", + headers: { + host: "some-saleor-host.cloud", + "x-forwarded-proto": "https", + "saleor-api-url": "https://mock-saleor-domain.saleor.cloud/graphql/", + "saleor-domain": "https://mock-saleor-domain.saleor.cloud/", + }, + }); + + const webhook = new SaleorSyncWebhook({ + apl: mockApl, + webhookPath: "/test", + event: "CHECKOUT_CALCULATE_TAXES", + query: "", + name: "Webhook test name", + isActive: true, + }); + + const handler = webhook.createHandler((_req, _res, ctx) => { + _res.send( + ctx.buildResponse({ + lines: [ + { + tax_rate: "VAT8", + total_net_amount: 10, + total_gross_amount: 1.08, + }, + ], + shipping_price_gross_amount: 2, + shipping_tax_rate: "VAT8", + shipping_price_net_amount: 1, + }) + ); + }); + + await handler(req, res); + + expect(res._getData()).toEqual( + expect.objectContaining({ + lines: [ + { + tax_rate: "VAT8", + total_net_amount: 10, + total_gross_amount: 1.08, + }, + ], + shipping_price_gross_amount: 2, + shipping_tax_rate: "VAT8", + shipping_price_net_amount: 1, + }) + ); + }); +}); diff --git a/src/handlers/next/saleor-webhooks/saleor-sync-webhook.ts b/src/handlers/next/saleor-webhooks/saleor-sync-webhook.ts new file mode 100644 index 0000000..3ab6b63 --- /dev/null +++ b/src/handlers/next/saleor-webhooks/saleor-sync-webhook.ts @@ -0,0 +1,39 @@ +import { NextApiHandler } from "next"; + +import { SyncWebhookEventType } from "../../../types"; +import { NextWebhookApiHandler, SaleorWebhook, WebhookConfig } from "./saleor-webhook"; +import { buildSyncWebhookResponsePayload } from "./sync-webhook-response-builder"; + +type InjectedContext = { + buildResponse: typeof buildSyncWebhookResponsePayload; +}; + +export class SaleorSyncWebhook< + TPayload = unknown, + TEvent extends SyncWebhookEventType = SyncWebhookEventType +> extends SaleorWebhook> { + readonly event: TEvent; + + protected readonly eventType = "sync" as const; + + protected extraContext = { + buildResponse: buildSyncWebhookResponsePayload, + }; + + constructor(configuration: WebhookConfig) { + super(configuration); + + this.event = configuration.event; + } + + createHandler( + handlerFn: NextWebhookApiHandler< + TPayload, + { + buildResponse: typeof buildSyncWebhookResponsePayload; + } + > + ): NextApiHandler { + return super.createHandler(handlerFn); + } +} diff --git a/src/handlers/next/saleor-async-webhook.ts b/src/handlers/next/saleor-webhooks/saleor-webhook.ts similarity index 54% rename from src/handlers/next/saleor-async-webhook.ts rename to src/handlers/next/saleor-webhooks/saleor-webhook.ts index e49be13..bc90d7e 100644 --- a/src/handlers/next/saleor-async-webhook.ts +++ b/src/handlers/next/saleor-webhooks/saleor-webhook.ts @@ -1,23 +1,23 @@ import { ASTNode } from "graphql"; import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; -import { APL } from "../../APL"; -import { createDebug } from "../../debug"; -import { gqlAstToString } from "../../gql-ast-to-string"; -import { AsyncWebhookEventType, WebhookManifest } from "../../types"; +import { APL } from "../../../APL"; +import { createDebug } from "../../../debug"; +import { gqlAstToString } from "../../../gql-ast-to-string"; +import { AsyncWebhookEventType, SyncWebhookEventType, WebhookManifest } from "../../../types"; import { - processAsyncSaleorWebhook, + processSaleorWebhook, SaleorWebhookError, WebhookContext, WebhookError, -} from "./process-async-saleor-webhook"; +} from "./process-saleor-webhook"; const debug = createDebug("SaleorAsyncWebhook"); -interface WebhookManifestConfigurationBase { +export interface WebhookConfig { name?: string; webhookPath: string; - asyncEvent: AsyncWebhookEventType; + event: Event; isActive?: boolean; apl: APL; onError?(error: WebhookError | Error, req: NextApiRequest, res: NextApiResponse): void; @@ -29,21 +29,14 @@ interface WebhookManifestConfigurationBase { code: number; body: object | string; }>; + query: string | ASTNode; + /** + * @deprecated will be removed in 0.35.0, use query field instead + */ + subscriptionQueryAst?: ASTNode; } -interface WebhookManifestConfigurationWithAst extends WebhookManifestConfigurationBase { - subscriptionQueryAst: ASTNode; -} - -interface WebhookManifestConfigurationWithQuery extends WebhookManifestConfigurationBase { - query: string; -} - -type WebhookManifestConfiguration = - | WebhookManifestConfigurationWithAst - | WebhookManifestConfigurationWithQuery; - -export const AsyncWebhookErrorCodeMap: Record = { +export const WebhookErrorCodeMap: Record = { OTHER: 500, MISSING_HOST_HEADER: 400, MISSING_DOMAIN_HEADER: 400, @@ -60,63 +53,63 @@ export const AsyncWebhookErrorCodeMap: Record = { CONFIGURATION_ERROR: 500, }; -export type NextWebhookApiHandler = ( +export type NextWebhookApiHandler = ( req: NextApiRequest, - res: NextApiResponse, - ctx: WebhookContext + res: NextApiResponse, + ctx: WebhookContext & TExtras ) => unknown | Promise; -export class SaleorAsyncWebhook { +export abstract class SaleorWebhook< + TPayload = unknown, + TExtras extends Record = {} +> { + protected abstract eventType: "async" | "sync"; + + protected extraContext?: TExtras; + name: string; webhookPath: string; - subscriptionQueryAst?: ASTNode; + query: string | ASTNode; - query?: string; - - asyncEvent: AsyncWebhookEventType; + event: AsyncWebhookEventType | SyncWebhookEventType; isActive?: boolean; apl: APL; - onError: WebhookManifestConfigurationBase["onError"]; + onError: WebhookConfig["onError"]; - formatErrorResponse: WebhookManifestConfigurationBase["formatErrorResponse"]; + formatErrorResponse: WebhookConfig["formatErrorResponse"]; - constructor(configuration: WebhookManifestConfiguration) { - const { name, webhookPath, asyncEvent, apl, isActive = true } = configuration; - this.name = name || `${asyncEvent} webhook`; - if ("query" in configuration) { - this.query = configuration.query; - } - if ("subscriptionQueryAst" in configuration) { - this.subscriptionQueryAst = configuration.subscriptionQueryAst; - } - if (!this.subscriptionQueryAst && !this.query) { - throw new WebhookError( - "Need to specify `subscriptionQueryAst` or `query` to create webhook subscription", - "CONFIGURATION_ERROR" - ); - } + protected constructor(configuration: WebhookConfig) { + const { + name, + webhookPath, + event, + query, + apl, + isActive = true, + subscriptionQueryAst, + } = configuration; + this.name = name || `${event} webhook`; + /** + * Fallback subscriptionQueryAst to avoid breaking changes + * + * TODO Remove in 0.35.0 + */ + this.query = query ?? subscriptionQueryAst; this.webhookPath = webhookPath; - this.asyncEvent = asyncEvent; + this.event = event; this.isActive = isActive; this.apl = apl; this.onError = configuration.onError; this.formatErrorResponse = configuration.formatErrorResponse; } - /** - * Returns full URL to the webhook, based on provided baseUrl. - * - * TODO: Shouldn't it be private? - * - * @param baseUrl Base URL used by your application - */ - getTargetUrl(baseUrl: string) { + private getTargetUrl(baseUrl: string) { return new URL(this.webhookPath, baseUrl).href; } @@ -127,34 +120,47 @@ export class SaleorAsyncWebhook { * @returns WebhookManifest */ getWebhookManifest(baseUrl: string): WebhookManifest { - return { + const manifestBase: Omit = { + query: typeof this.query === "string" ? this.query : gqlAstToString(this.query), name: this.name, targetUrl: this.getTargetUrl(baseUrl), - asyncEvents: [this.asyncEvent], isActive: this.isActive, - // Query can be provided as plaintext.. - ...(this.query && { query: this.query }), - // ...GQL AST which has to be stringified.. - ...(this.subscriptionQueryAst && { query: gqlAstToString(this.subscriptionQueryAst) }), - // or no query at all. In such case default webhook payload will be sent }; + + switch (this.eventType) { + case "async": + return { + ...manifestBase, + asyncEvents: [this.event as AsyncWebhookEventType], + }; + case "sync": + return { + ...manifestBase, + syncEvents: [this.event as SyncWebhookEventType], + }; + default: { + throw new Error("Class extended incorrectly"); + } + } } /** * Wraps provided function, to ensure incoming request comes from registered Saleor instance. * Also provides additional `context` object containing typed payload and request properties. */ - createHandler(handlerFn: NextWebhookApiHandler): NextApiHandler { + createHandler(handlerFn: NextWebhookApiHandler): NextApiHandler { return async (req, res) => { debug(`Handler for webhook ${this.name} called`); - await processAsyncSaleorWebhook({ + + await processSaleorWebhook({ req, apl: this.apl, - allowedEvent: this.asyncEvent, + allowedEvent: this.event, }) .then(async (context) => { debug("Incoming request validated. Call handlerFn"); - return handlerFn(req, res, context); + + return handlerFn(req, res, { ...(this.extraContext ?? ({} as TExtras)), ...context }); }) .catch(async (e) => { debug(`Unexpected error during processing the webhook ${this.name}`); @@ -174,7 +180,7 @@ export class SaleorAsyncWebhook { return; } - res.status(AsyncWebhookErrorCodeMap[e.errorType] || 400).send({ + res.status(WebhookErrorCodeMap[e.errorType] || 400).send({ error: { type: e.errorType, message: e.message, diff --git a/src/handlers/next/saleor-webhooks/sync-webhook-response-builder.ts b/src/handlers/next/saleor-webhooks/sync-webhook-response-builder.ts new file mode 100644 index 0000000..adf40a7 --- /dev/null +++ b/src/handlers/next/saleor-webhooks/sync-webhook-response-builder.ts @@ -0,0 +1,39 @@ +import { SyncWebhookEventType } from "../../../types"; + +/** + * TODO Confirm with Saleor Core source (not the docs) to check if its 100% accurate + */ +type SyncWebhookResponsesMap = { + CHECKOUT_CALCULATE_TAXES: { + shipping_price_gross_amount: number; + shipping_price_net_amount: number; + shipping_tax_rate: string; + lines: Array<{ + total_gross_amount: number; + total_net_amount: number; + tax_rate: string; + }>; + }; + CHECKOUT_FILTER_SHIPPING_METHODS: { + excluded_methods: Array<{ + id: string; + reason: string; + }>; + }; + ORDER_CALCULATE_TAXES: SyncWebhookResponsesMap["CHECKOUT_CALCULATE_TAXES"]; + ORDER_FILTER_SHIPPING_METHODS: SyncWebhookResponsesMap["CHECKOUT_FILTER_SHIPPING_METHODS"]; + SHIPPING_LIST_METHODS_FOR_CHECKOUT: Array<{ + id: string; + name: string; + amount: number; + currency: string; // or enum? + maximum_delivery_days?: number; + }>; +}; + +/** + * Identity function, but it works on Typescript level to pick right payload based on first param + */ +export const buildSyncWebhookResponsePayload = ( + payload: SyncWebhookResponsesMap[E] +): SyncWebhookResponsesMap[E] => payload; diff --git a/src/middleware/with-registered-saleor-domain-header.test.ts b/src/middleware/with-registered-saleor-domain-header.test.ts index 9d4b38b..ac093c4 100644 --- a/src/middleware/with-registered-saleor-domain-header.test.ts +++ b/src/middleware/with-registered-saleor-domain-header.test.ts @@ -2,9 +2,9 @@ import { Handler, Request } from "retes"; import { Response } from "retes/response"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { APL } from "../APL"; -import { SALEOR_DOMAIN_HEADER } from "../const"; +import { SALEOR_API_URL_HEADER, SALEOR_DOMAIN_HEADER } from "../const"; import { SaleorApp } from "../saleor-app"; +import { MockAPL } from "../test-utils/mock-apl"; import { withRegisteredSaleorDomainHeader } from "./with-registered-saleor-domain-header"; import { withSaleorApp } from "./with-saleor-app"; @@ -14,18 +14,7 @@ describe("middleware", () => { describe("withRegisteredSaleorDomainHeader", () => { let mockHandlerFn: Handler = vi.fn(getMockSuccessResponse); - const mockAPL: APL = { - get: async (domain: string) => - domain === "example.com" - ? { - domain: "example.com", - token: "mock-token", - } - : undefined, - set: vi.fn(), - delete: vi.fn(), - getAll: vi.fn(), - }; + const mockAPL = new MockAPL(); beforeEach(() => { mockHandlerFn = vi.fn(getMockSuccessResponse); @@ -37,7 +26,8 @@ describe("middleware", () => { headers: { host: "my-saleor-env.saleor.cloud", "x-forwarded-proto": "https", - [SALEOR_DOMAIN_HEADER]: "example.com", + [SALEOR_DOMAIN_HEADER]: mockAPL.workingSaleorDomain, + [SALEOR_API_URL_HEADER]: mockAPL.workingSaleorApiUrl, }, } as unknown as Request; @@ -60,6 +50,7 @@ describe("middleware", () => { host: "my-saleor-env.saleor.cloud", "x-forwarded-proto": "https", [SALEOR_DOMAIN_HEADER]: "not-registered.example.com", + [SALEOR_API_URL_HEADER]: "https://not-registered.example.com/graphql/", }, } as unknown as Request; @@ -80,7 +71,8 @@ describe("middleware", () => { headers: { host: "my-saleor-env.saleor.cloud", "x-forwarded-proto": "https", - [SALEOR_DOMAIN_HEADER]: "example.com", + [SALEOR_DOMAIN_HEADER]: mockAPL.workingSaleorDomain, + [SALEOR_API_URL_HEADER]: mockAPL.workingSaleorApiUrl, }, } as unknown as Request; diff --git a/src/middleware/with-registered-saleor-domain-header.ts b/src/middleware/with-registered-saleor-domain-header.ts index 84c7ba6..7551e2c 100644 --- a/src/middleware/with-registered-saleor-domain-header.ts +++ b/src/middleware/with-registered-saleor-domain-header.ts @@ -8,16 +8,16 @@ import { getSaleorAppFromRequest } from "./with-saleor-app"; const debug = createMiddlewareDebug("withRegisteredSaleorDomainHeader"); export const withRegisteredSaleorDomainHeader: Middleware = (handler) => async (request) => { - const { domain: saleorDomain } = getSaleorHeaders(request.headers); + const { saleorApiUrl } = getSaleorHeaders(request.headers); - if (!saleorDomain) { + if (!saleorApiUrl) { return Response.BadRequest({ success: false, - message: "Domain header missing.", + message: "saleorApiUrl header missing.", }); } - debug("Middleware called with domain: \"%s\"", saleorDomain); + debug("Middleware called with saleorApiUrl: \"%s\"", saleorApiUrl); const saleorApp = getSaleorAppFromRequest(request); @@ -32,14 +32,14 @@ export const withRegisteredSaleorDomainHeader: Middleware = (handler) => async ( }); } - const authData = await saleorApp?.apl.get(saleorDomain); + const authData = await saleorApp?.apl.get(saleorApiUrl); if (!authData) { debug("Auth was not found in APL, will respond with Forbidden status"); return Response.Forbidden({ success: false, - message: `Domain ${saleorDomain} not registered.`, + message: `Saleor: ${saleorApiUrl} not registered.`, }); } diff --git a/src/test-utils/mock-apl.ts b/src/test-utils/mock-apl.ts new file mode 100644 index 0000000..28a84c8 --- /dev/null +++ b/src/test-utils/mock-apl.ts @@ -0,0 +1,70 @@ +import { vi } from "vitest"; + +import { APL, AuthData } from "../APL"; + +type Options = { + workForApiUrl?: string; + savedAllAuthData?: AuthData[]; +}; + +/** + * Test utility used across scenarios to simulate various APL behaviors + */ +export class MockAPL implements APL { + private readonly options: Options = { + workForApiUrl: "https://example.com/graphql/", + savedAllAuthData: [], + }; + + constructor(opts?: Options) { + this.options = { + ...this.options, + ...(opts ?? {}), + }; + + this.workingSaleorApiUrl = this.options.workForApiUrl ?? this.workingSaleorApiUrl; + } + + mockJwks = "{}"; + + mockToken = "mock-token"; + + mockAppId = "mock-app-id"; + + workingSaleorApiUrl = "https://example.com/graphql/"; + + resolveDomainFromApiUrl = (apiUrl: string) => + apiUrl.replace("/graphql/", "").replace("https://", ""); + + get workingSaleorDomain() { + return this.resolveDomainFromApiUrl(this.workingSaleorApiUrl); + } + + async get(saleorApiUrl: string) { + if (saleorApiUrl === this.workingSaleorApiUrl) { + return { + domain: this.resolveDomainFromApiUrl(saleorApiUrl), + token: this.mockToken, + saleorApiUrl, + appId: this.mockAppId, + jwks: this.mockJwks, + }; + } + + return undefined; + } + + set = vi.fn(); + + delete = vi.fn(); + + getAll = vi.fn().mockImplementation(async () => this.options.savedAllAuthData); + + isReady = vi.fn().mockImplementation(async () => ({ + ready: true, + })); + + isConfigured = vi.fn().mockImplementation(async () => ({ + configured: true, + })); +} diff --git a/src/types.ts b/src/types.ts index 0e38ac0..e3c8349 100644 --- a/src/types.ts +++ b/src/types.ts @@ -163,15 +163,10 @@ export type AsyncWebhookEventType = /** * @see https://github.com/saleor/saleor/blob/main/saleor/graphql/schema.graphql#L1995 + * + * TODO Add Payment events after new API in 3.13 */ export type SyncWebhookEventType = - | "PAYMENT_LIST_GATEWAYS" - | "PAYMENT_AUTHORIZE" - | "PAYMENT_CAPTURE" - | "PAYMENT_REFUND" - | "PAYMENT_VOID" - | "PAYMENT_CONFIRM" - | "PAYMENT_PROCESS" | "CHECKOUT_CALCULATE_TAXES" | "ORDER_CALCULATE_TAXES" | "SHIPPING_LIST_METHODS_FOR_CHECKOUT"