
* 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>
305 lines
8.4 KiB
Markdown
305 lines
8.4 KiB
Markdown
# 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
|
|
});
|
|
```
|