@shopify/shopify-api
Version:
Shopify API Library for Node - accelerate development with support for authentication, graphql proxy, webhooks
342 lines (339 loc) • 12.5 kB
JavaScript
import { graphqlClientClass } from '../clients/admin/graphql/client.mjs';
import '@shopify/admin-api-client';
import '@shopify/network';
import { InvalidDeliveryMethodError, ShopifyError } from '../error.mjs';
import { privacyTopics } from '../types.mjs';
import '../../runtime/crypto/crypto.mjs';
import '../../runtime/crypto/types.mjs';
import { logger } from '../logger/index.mjs';
import { getHandlers, handlerIdentifier, addHostToCallbackUrl } from './registry.mjs';
import { queryTemplate } from './query-template.mjs';
import { WebhookOperation, DeliveryMethod } from './types.mjs';
function register(config, webhookRegistry) {
return async function register({ session, }) {
const log = logger(config);
log.info('Registering webhooks', { shop: session.shop });
const registerReturn = Object.keys(webhookRegistry).reduce((acc, topic) => {
acc[topic] = [];
return acc;
}, {});
const existingHandlers = await getExistingHandlers(config, session);
log.debug(`Existing topics: [${Object.keys(existingHandlers).join(', ')}]`, { shop: session.shop });
for (const topic in webhookRegistry) {
if (!Object.prototype.hasOwnProperty.call(webhookRegistry, topic)) {
continue;
}
if (privacyTopics.includes(topic)) {
continue;
}
registerReturn[topic] = await registerTopic({
config,
session,
topic,
existingHandlers: existingHandlers[topic] || [],
handlers: getHandlers(webhookRegistry)(topic),
});
// Remove this topic from the list of existing handlers so we have a list of leftovers
delete existingHandlers[topic];
}
// Delete any leftover handlers
for (const topic in existingHandlers) {
if (!Object.prototype.hasOwnProperty.call(existingHandlers, topic)) {
continue;
}
const GraphqlClient = graphqlClientClass({ config });
const client = new GraphqlClient({ session });
registerReturn[topic] = await runMutations({
config,
client,
topic,
handlers: existingHandlers[topic],
operation: WebhookOperation.Delete,
});
}
return registerReturn;
};
}
async function getExistingHandlers(config, session) {
const GraphqlClient = graphqlClientClass({ config });
const client = new GraphqlClient({ session });
const existingHandlers = {};
let hasNextPage;
let endCursor = null;
do {
const query = buildCheckQuery(endCursor);
const response = await client.request(query);
response.data?.webhookSubscriptions?.edges.forEach((edge) => {
const handler = buildHandlerFromNode(edge);
if (!existingHandlers[edge.node.topic]) {
existingHandlers[edge.node.topic] = [];
}
existingHandlers[edge.node.topic].push(handler);
});
endCursor = response.data?.webhookSubscriptions?.pageInfo.endCursor;
hasNextPage = response.data?.webhookSubscriptions?.pageInfo.hasNextPage;
} while (hasNextPage);
return existingHandlers;
}
function buildCheckQuery(endCursor) {
return queryTemplate(TEMPLATE_GET_HANDLERS, {
END_CURSOR: JSON.stringify(endCursor),
});
}
function buildHandlerFromNode(edge) {
const endpoint = edge.node.endpoint;
let handler;
switch (endpoint.__typename) {
case 'WebhookHttpEndpoint':
handler = {
deliveryMethod: DeliveryMethod.Http,
callbackUrl: endpoint.callbackUrl,
// This is a dummy for now because we don't really care about it
callback: async () => { },
};
break;
case 'WebhookEventBridgeEndpoint':
handler = {
deliveryMethod: DeliveryMethod.EventBridge,
arn: endpoint.arn,
};
break;
case 'WebhookPubSubEndpoint':
handler = {
deliveryMethod: DeliveryMethod.PubSub,
pubSubProject: endpoint.pubSubProject,
pubSubTopic: endpoint.pubSubTopic,
};
break;
}
// Set common fields
handler.id = edge.node.id;
handler.includeFields = edge.node.includeFields;
handler.metafieldNamespaces = edge.node.metafieldNamespaces;
// Sort the array fields to make them cheaper to compare later on
handler.includeFields?.sort();
handler.metafieldNamespaces?.sort();
return handler;
}
async function registerTopic({ config, session, topic, existingHandlers, handlers, }) {
let registerResults = [];
const { toCreate, toUpdate, toDelete } = categorizeHandlers(config, existingHandlers, handlers);
const GraphqlClient = graphqlClientClass({ config });
const client = new GraphqlClient({ session });
let operation = WebhookOperation.Create;
registerResults = registerResults.concat(await runMutations({ config, client, topic, operation, handlers: toCreate }));
operation = WebhookOperation.Update;
registerResults = registerResults.concat(await runMutations({ config, client, topic, operation, handlers: toUpdate }));
operation = WebhookOperation.Delete;
registerResults = registerResults.concat(await runMutations({ config, client, topic, operation, handlers: toDelete }));
return registerResults;
}
function categorizeHandlers(config, existingHandlers, handlers) {
const handlersByKey = handlers.reduce((acc, value) => {
acc[handlerIdentifier(config, value)] = value;
return acc;
}, {});
const existingHandlersByKey = existingHandlers.reduce((acc, value) => {
acc[handlerIdentifier(config, value)] = value;
return acc;
}, {});
const toCreate = { ...handlersByKey };
const toUpdate = {};
const toDelete = {};
for (const existingKey in existingHandlersByKey) {
if (!Object.prototype.hasOwnProperty.call(existingHandlersByKey, existingKey)) {
continue;
}
const existingHandler = existingHandlersByKey[existingKey];
const handler = handlersByKey[existingKey];
if (existingKey in handlersByKey) {
delete toCreate[existingKey];
if (!areHandlerFieldsEqual(existingHandler, handler)) {
toUpdate[existingKey] = handler;
toUpdate[existingKey].id = existingHandler.id;
}
}
else {
toDelete[existingKey] = existingHandler;
}
}
return {
toCreate: Object.values(toCreate),
toUpdate: Object.values(toUpdate),
toDelete: Object.values(toDelete),
};
}
function areHandlerFieldsEqual(arr1, arr2) {
const includeFieldsEqual = arraysEqual(arr1.includeFields || [], arr2.includeFields || []);
const metafieldNamespacesEqual = arraysEqual(arr1.metafieldNamespaces || [], arr2.metafieldNamespaces || []);
return includeFieldsEqual && metafieldNamespacesEqual;
}
function arraysEqual(arr1, arr2) {
if (arr1.length !== arr2.length) {
return false;
}
for (let i = 0; i < arr1.length; i++) {
if (arr1[i] !== arr2[i]) {
return false;
}
}
return true;
}
async function runMutations({ config, client, topic, handlers, operation, }) {
const registerResults = [];
for (const handler of handlers) {
registerResults.push(await runMutation({ config, client, topic, handler, operation }));
}
return registerResults;
}
async function runMutation({ config, client, topic, handler, operation, }) {
let registerResult;
logger(config).debug(`Running webhook mutation`, { topic, operation });
try {
const query = buildMutation(config, topic, handler, operation);
const result = await client.request(query);
registerResult = {
deliveryMethod: handler.deliveryMethod,
success: isSuccess(result, handler, operation),
result,
operation,
};
}
catch (error) {
if (error instanceof InvalidDeliveryMethodError) {
registerResult = {
deliveryMethod: handler.deliveryMethod,
success: false,
result: { message: error.message },
operation,
};
}
else {
throw error;
}
}
return registerResult;
}
function buildMutation(config, topic, handler, operation) {
const params = {};
let identifier;
if (handler.id) {
identifier = `id: "${handler.id}"`;
}
else {
identifier = `topic: ${topic}`;
}
const mutationArguments = {
MUTATION_NAME: getMutationName(handler, operation),
IDENTIFIER: identifier,
MUTATION_PARAMS: '',
};
if (operation !== WebhookOperation.Delete) {
switch (handler.deliveryMethod) {
case DeliveryMethod.Http:
params.callbackUrl = `"${addHostToCallbackUrl(config, handler.callbackUrl)}"`;
break;
case DeliveryMethod.EventBridge:
params.arn = `"${handler.arn}"`;
break;
case DeliveryMethod.PubSub:
params.pubSubProject = `"${handler.pubSubProject}"`;
params.pubSubTopic = `"${handler.pubSubTopic}"`;
break;
default:
throw new InvalidDeliveryMethodError(`Unrecognized delivery method '${handler.deliveryMethod}'`);
}
if (handler.includeFields) {
params.includeFields = JSON.stringify(handler.includeFields);
}
if (handler.metafieldNamespaces) {
params.metafieldNamespaces = JSON.stringify(handler.metafieldNamespaces);
}
if (handler.subTopic) {
const subTopicString = `subTopic: "${handler.subTopic}",`;
mutationArguments.MUTATION_PARAMS = subTopicString;
}
const paramsString = Object.entries(params)
.map(([key, value]) => `${key}: ${value}`)
.join(', ');
mutationArguments.MUTATION_PARAMS += `webhookSubscription: {${paramsString}}`;
}
return queryTemplate(TEMPLATE_MUTATION, mutationArguments);
}
function getMutationName(handler, operation) {
switch (operation) {
case WebhookOperation.Create:
return `${getEndpoint(handler)}Create`;
case WebhookOperation.Update:
return `${getEndpoint(handler)}Update`;
case WebhookOperation.Delete:
return 'webhookSubscriptionDelete';
default:
throw new ShopifyError(`Unrecognized operation '${operation}'`);
}
}
function getEndpoint(handler) {
switch (handler.deliveryMethod) {
case DeliveryMethod.Http:
return 'webhookSubscription';
case DeliveryMethod.EventBridge:
return 'eventBridgeWebhookSubscription';
case DeliveryMethod.PubSub:
return 'pubSubWebhookSubscription';
default:
throw new ShopifyError(`Unrecognized delivery method '${handler.deliveryMethod}'`);
}
}
function isSuccess(result, handler, operation) {
const mutationName = getMutationName(handler, operation);
return Boolean(result.data &&
result.data[mutationName] &&
result.data[mutationName].userErrors.length === 0);
}
const TEMPLATE_GET_HANDLERS = `query shopifyApiReadWebhookSubscriptions {
webhookSubscriptions(
first: 250,
after: {{END_CURSOR}},
) {
edges {
node {
id
topic
includeFields
metafieldNamespaces
endpoint {
__typename
... on WebhookHttpEndpoint {
callbackUrl
}
... on WebhookEventBridgeEndpoint {
arn
}
... on WebhookPubSubEndpoint {
pubSubProject
pubSubTopic
}
}
}
}
pageInfo {
endCursor
hasNextPage
}
}
}`;
const TEMPLATE_MUTATION = `
mutation shopifyApiCreateWebhookSubscription {
{{MUTATION_NAME}}(
{{IDENTIFIER}},
{{MUTATION_PARAMS}}
) {
userErrors {
field
message
}
}
}
`;
export { TEMPLATE_GET_HANDLERS, TEMPLATE_MUTATION, register };
//# sourceMappingURL=register.mjs.map