Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/polite-olives-wash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/shopify-api': minor
---

Add filter field support to webhook handlers
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ Fields to be included in the callback, defaulting to including all of them.

Namespaces to be included in the callback, defaulting to including all of them.

### filter

`string` | Optional

A filter string (using Shopify's [filter syntax](https://shopify.dev/docs/apps/webhooks/configuration/eventbridge#subscribe-to-standard-topics)) that limits which webhook payloads are delivered. For example, `"id:>0 AND title:shoes"`. When omitted, all payloads for the topic are delivered.

### subTopic
`string` | Deprecated

Expand Down
72 changes: 72 additions & 0 deletions packages/apps/shopify-api/lib/webhooks/__tests__/register.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,78 @@ describe('shopify.webhooks.register', () => {
assertRegisterResponse({registerReturn, topic, responses});
});

it('sends a request with a filter field', async () => {
const shopify = shopifyApi(testConfig());

const topic = 'PRODUCTS_CREATE';
const handler: WebhookHandler = {...HTTP_HANDLER, filter: 'title:shoes'};
const responses = [mockResponses.successResponse];

const registerReturn = await registerWebhook({
shopify,
topic,
handler,
responses,
});

assertWebhookRegistrationRequest(
shopify.config.apiVersion,
'webhookSubscriptionCreate',
`topic: ${topic}`,
{
callbackUrl: `"https://test_host_name/webhooks"`,
filter: '"title:shoes"',
},
);
assertRegisterResponse({registerReturn, topic, responses});
});

it('updates a pre-existing webhook when the filter changes', async () => {
const shopify = shopifyApi(testConfig());

const topic = 'PRODUCTS_CREATE';
const handler: WebhookHandler = {...HTTP_HANDLER, filter: 'title:boots'};
const responses = [mockResponses.successUpdateResponse];

const registerReturn = await registerWebhook({
shopify,
topic,
handler,
checkMockResponse: mockResponses.webhookCheckResponseWithFilter,
responses,
});

assertWebhookRegistrationRequest(
shopify.config.apiVersion,
'webhookSubscriptionUpdate',
`id: "${mockResponses.TEST_WEBHOOK_ID}"`,
{
callbackUrl: `"https://test_host_name/webhooks"`,
filter: '"title:boots"',
},
);
assertRegisterResponse({registerReturn, topic, responses});
});

it('does not update a pre-existing webhook when the filter is unchanged', async () => {
const shopify = shopifyApi(testConfig());

const topic = 'PRODUCTS_CREATE';
const handler: WebhookHandler = {...HTTP_HANDLER, filter: 'title:shoes'};

shopify.webhooks.addHandlers({[topic]: handler});

queueMockResponse(
JSON.stringify(mockResponses.webhookCheckResponseWithFilter),
);

const registerReturn = await shopify.webhooks.register({session});

// Only the check query should have been sent — no mutation
expect(mockTestRequests.requestList).toHaveLength(1);
expect(registerReturn[topic]).toHaveLength(0);
});

it('returns a result with success set to false, body set to empty object, when the server doesn’t return a webhookSubscriptionCreate field', async () => {
const shopify = shopifyApi(testConfig());

Expand Down
24 changes: 24 additions & 0 deletions packages/apps/shopify-api/lib/webhooks/__tests__/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,30 @@ export const webhookCheckMultiHandlerResponse = {
},
};

export const webhookCheckResponseWithFilter = {
data: {
webhookSubscriptions: {
edges: [
{
node: {
id: TEST_WEBHOOK_ID,
topic: 'PRODUCTS_CREATE',
filter: 'title:shoes',
endpoint: {
__typename: 'WebhookHttpEndpoint',
callbackUrl: 'https://test_host_name/webhooks',
},
},
},
],
pageInfo: {
endCursor: null,
hasNextPage: false,
},
},
},
};

export const eventBridgeWebhookCheckResponse = {
data: {
webhookSubscriptions: {
Expand Down
8 changes: 7 additions & 1 deletion packages/apps/shopify-api/lib/webhooks/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ function buildHandlerFromNode(edge: WebhookCheckResponseNode): WebhookHandler {
handler.id = edge.node.id;
handler.includeFields = edge.node.includeFields;
handler.metafieldNamespaces = edge.node.metafieldNamespaces;
handler.filter = edge.node.filter;

// Sort the array fields to make them cheaper to compare later on
handler.includeFields?.sort();
Expand Down Expand Up @@ -291,8 +292,9 @@ function areHandlerFieldsEqual(
arr1.metafieldNamespaces || [],
arr2.metafieldNamespaces || [],
);
const filterEqual = (arr1.filter ?? '') === (arr2.filter ?? '');

return includeFieldsEqual && metafieldNamespacesEqual;
return includeFieldsEqual && metafieldNamespacesEqual && filterEqual;
}

function arraysEqual(arr1: any[], arr2: any[]): boolean {
Expand Down Expand Up @@ -413,6 +415,9 @@ function buildMutation(
if (handler.metafieldNamespaces) {
params.metafieldNamespaces = JSON.stringify(handler.metafieldNamespaces);
}
if (handler.filter) {
params.filter = `"${handler.filter}"`;
}

if (handler.subTopic) {
const subTopicString = `subTopic: "${handler.subTopic}",`;
Expand Down Expand Up @@ -485,6 +490,7 @@ export const TEMPLATE_GET_HANDLERS = `query shopifyApiReadWebhookSubscriptions {
topic
includeFields
metafieldNamespaces
filter
endpoint {
__typename
... on WebhookHttpEndpoint {
Expand Down
2 changes: 2 additions & 0 deletions packages/apps/shopify-api/lib/webhooks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ interface BaseWebhookHandler {
id?: string;
includeFields?: string[];
metafieldNamespaces?: string[];
filter?: string;
subTopic?: string;
context?: any;
}
Expand Down Expand Up @@ -141,6 +142,7 @@ export interface WebhookCheckResponseNode<
topic: string;
includeFields: string[];
metafieldNamespaces: string[];
filter?: string;
} & T;
}

Expand Down
Loading