Add sync webhook (#202)
* Change process-async-saleor-webhook to process-saleor-webhook and add better debug logs * Change processWebhook name * wip * Add base class structure * refactor * add sync payload builder * wip * wip * Add some missing webhook responses * Fix tests for Async version * Fix tests * Add MockAPL test util * Refactor tests to use MockAPL * Add test to sync webhook * Restore legacy subscriptionQueryAst field for compatibility * CR Fixes - TS type & event -> eventType * Changelog, docs and remove old payment webhooks * Apply suggestions from code review Co-authored-by: Krzysztof Wolski <krzysztof.k.wolski@gmail.com> * Update src/handlers/next/saleor-webhooks/process-saleor-webhook.ts Co-authored-by: Krzysztof Wolski <krzysztof.k.wolski@gmail.com> --------- Co-authored-by: Krzysztof Wolski <krzysztof.k.wolski@gmail.com>
This commit is contained in:
parent
bc6e68db84
commit
9420209a2d
22 changed files with 812 additions and 447 deletions
15
.changeset/afraid-poets-visit.md
Normal file
15
.changeset/afraid-poets-visit.md
Normal file
|
@ -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
|
|
@ -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": {
|
||||
|
|
|
@ -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<OrderPayload>({
|
||||
/**
|
||||
* 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<OrderPayload>({
|
||||
// ... 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<ProductUpdatedWebhookPayloadFragment>({
|
||||
name: "Example product updated webhook",
|
||||
webhookPath: "api/webhooks/saleor/product-updated",
|
||||
asyncEvent: "PRODUCT_UPDATED",
|
||||
apl: saleorApp.apl,
|
||||
subscriptionQueryAst: ExampleProductUpdatedSubscription,
|
||||
});
|
||||
```
|
305
docs/saleor-webhook.md
Normal file
305
docs/saleor-webhook.md
Normal file
|
@ -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<OrderPayload>(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<OrderPayload>(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<OrderPayload>({
|
||||
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<Payload>({
|
||||
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<OrderPayload>({
|
||||
// ... 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<ProductUpdatedWebhookPayloadFragment>({
|
||||
name: "Example product updated webhook",
|
||||
webhookPath: "api/webhooks/saleor/product-updated",
|
||||
event: "PRODUCT_UPDATED",
|
||||
apl: saleorApp.apl,
|
||||
query: ExampleProductUpdatedSubscription, // Or use plain string
|
||||
});
|
||||
```
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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({
|
||||
/**
|
||||
|
|
|
@ -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");
|
||||
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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",
|
||||
});
|
||||
|
|
|
@ -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({
|
||||
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",
|
|
@ -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 = <T = unknown>(
|
||||
type ProcessSaleorWebhook = <T = unknown>(
|
||||
props: ProcessSaleorWebhookArgs
|
||||
) => Promise<WebhookContext<T>>;
|
||||
|
||||
|
@ -66,16 +66,18 @@ type ProcessAsyncSaleorWebhook = <T = unknown>(
|
|||
*
|
||||
* @returns WebhookContext
|
||||
*/
|
||||
export const processAsyncSaleorWebhook: ProcessAsyncSaleorWebhook = async <T>({
|
||||
export const processSaleorWebhook: ProcessSaleorWebhook = async <T>({
|
||||
req,
|
||||
apl,
|
||||
allowedEvent,
|
||||
}: ProcessSaleorWebhookArgs): Promise<WebhookContext<T>> => {
|
||||
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 <T>({
|
|||
}
|
||||
|
||||
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 <T>({
|
|||
|
||||
if (!signature) {
|
||||
debug("No signature");
|
||||
|
||||
throw new WebhookError("Missing saleor-signature header", "MISSING_SIGNATURE_HEADER");
|
||||
}
|
||||
|
||||
|
@ -116,41 +121,65 @@ export const processAsyncSaleorWebhook: ProcessAsyncSaleorWebhook = async <T>({
|
|||
).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");
|
||||
}
|
||||
}
|
|
@ -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<AsyncWebhookEventType> = {
|
||||
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.
|
30
src/handlers/next/saleor-webhooks/saleor-async-webhook.ts
Normal file
30
src/handlers/next/saleor-webhooks/saleor-async-webhook.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { NextApiHandler } from "next";
|
||||
|
||||
import { AsyncWebhookEventType } from "../../../types";
|
||||
import { NextWebhookApiHandler, SaleorWebhook, WebhookConfig } from "./saleor-webhook";
|
||||
|
||||
export class SaleorAsyncWebhook<TPayload = unknown> extends SaleorWebhook<TPayload> {
|
||||
readonly event: AsyncWebhookEventType;
|
||||
|
||||
protected readonly eventType = "async" as const;
|
||||
|
||||
constructor(
|
||||
configuration: WebhookConfig<AsyncWebhookEventType> & {
|
||||
/**
|
||||
* @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<TPayload>): NextApiHandler {
|
||||
return super.createHandler(handlerFn);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
39
src/handlers/next/saleor-webhooks/saleor-sync-webhook.ts
Normal file
39
src/handlers/next/saleor-webhooks/saleor-sync-webhook.ts
Normal file
|
@ -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<TEvent extends SyncWebhookEventType> = {
|
||||
buildResponse: typeof buildSyncWebhookResponsePayload<TEvent>;
|
||||
};
|
||||
|
||||
export class SaleorSyncWebhook<
|
||||
TPayload = unknown,
|
||||
TEvent extends SyncWebhookEventType = SyncWebhookEventType
|
||||
> extends SaleorWebhook<TPayload, InjectedContext<TEvent>> {
|
||||
readonly event: TEvent;
|
||||
|
||||
protected readonly eventType = "sync" as const;
|
||||
|
||||
protected extraContext = {
|
||||
buildResponse: buildSyncWebhookResponsePayload,
|
||||
};
|
||||
|
||||
constructor(configuration: WebhookConfig<TEvent>) {
|
||||
super(configuration);
|
||||
|
||||
this.event = configuration.event;
|
||||
}
|
||||
|
||||
createHandler(
|
||||
handlerFn: NextWebhookApiHandler<
|
||||
TPayload,
|
||||
{
|
||||
buildResponse: typeof buildSyncWebhookResponsePayload<TEvent>;
|
||||
}
|
||||
>
|
||||
): NextApiHandler {
|
||||
return super.createHandler(handlerFn);
|
||||
}
|
||||
}
|
|
@ -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<Event = AsyncWebhookEventType | SyncWebhookEventType> {
|
||||
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<SaleorWebhookError, number> = {
|
||||
export const WebhookErrorCodeMap: Record<SaleorWebhookError, number> = {
|
||||
OTHER: 500,
|
||||
MISSING_HOST_HEADER: 400,
|
||||
MISSING_DOMAIN_HEADER: 400,
|
||||
|
@ -60,63 +53,63 @@ export const AsyncWebhookErrorCodeMap: Record<SaleorWebhookError, number> = {
|
|||
CONFIGURATION_ERROR: 500,
|
||||
};
|
||||
|
||||
export type NextWebhookApiHandler<TPayload = unknown, TResp = unknown> = (
|
||||
export type NextWebhookApiHandler<TPayload = unknown, TExtras = {}> = (
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<TResp>,
|
||||
ctx: WebhookContext<TPayload>
|
||||
res: NextApiResponse,
|
||||
ctx: WebhookContext<TPayload> & TExtras
|
||||
) => unknown | Promise<unknown>;
|
||||
|
||||
export class SaleorAsyncWebhook<TPayload = unknown> {
|
||||
export abstract class SaleorWebhook<
|
||||
TPayload = unknown,
|
||||
TExtras extends Record<string, unknown> = {}
|
||||
> {
|
||||
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<TPayload = unknown> {
|
|||
* @returns WebhookManifest
|
||||
*/
|
||||
getWebhookManifest(baseUrl: string): WebhookManifest {
|
||||
return {
|
||||
const manifestBase: Omit<WebhookManifest, "asyncEvents" | "syncEvents"> = {
|
||||
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<TPayload>): NextApiHandler {
|
||||
createHandler(handlerFn: NextWebhookApiHandler<TPayload, TExtras>): NextApiHandler {
|
||||
return async (req, res) => {
|
||||
debug(`Handler for webhook ${this.name} called`);
|
||||
await processAsyncSaleorWebhook<TPayload>({
|
||||
|
||||
await processSaleorWebhook<TPayload>({
|
||||
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<TPayload = unknown> {
|
|||
return;
|
||||
}
|
||||
|
||||
res.status(AsyncWebhookErrorCodeMap[e.errorType] || 400).send({
|
||||
res.status(WebhookErrorCodeMap[e.errorType] || 400).send({
|
||||
error: {
|
||||
type: e.errorType,
|
||||
message: e.message,
|
|
@ -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 = <E extends SyncWebhookEventType>(
|
||||
payload: SyncWebhookResponsesMap[E]
|
||||
): SyncWebhookResponsesMap[E] => payload;
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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.`,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
70
src/test-utils/mock-apl.ts
Normal file
70
src/test-utils/mock-apl.ts
Normal file
|
@ -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,
|
||||
}));
|
||||
}
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue