@shopify/shopify-api
Version:
Shopify API Library for Node - accelerate development with support for authentication, graphql proxy, webhooks
523 lines (448 loc) • 13.2 kB
text/typescript
import {graphqlClientClass, GraphqlClient} from '../clients/admin';
import {InvalidDeliveryMethodError, ShopifyError} from '../error';
import {logger} from '../logger';
import {privacyTopics} from '../types';
import {ConfigInterface} from '../base-types';
import {Session} from '../session/session';
import {addHostToCallbackUrl, getHandlers, handlerIdentifier} from './registry';
import {queryTemplate} from './query-template';
import {
WebhookRegistry,
RegisterReturn,
WebhookHandler,
WebhookCheckResponse,
DeliveryMethod,
WebhookCheckResponseNode,
WebhookOperation,
RegisterResult,
RegisterParams,
} from './types';
interface RegisterTopicParams {
config: ConfigInterface;
session: Session;
topic: string;
existingHandlers: WebhookHandler[];
handlers: WebhookHandler[];
}
interface RunMutationsParams {
config: ConfigInterface;
client: GraphqlClient;
topic: string;
handlers: WebhookHandler[];
operation: WebhookOperation;
}
interface RunMutationParams {
config: ConfigInterface;
client: GraphqlClient;
topic: string;
handler: WebhookHandler;
operation: WebhookOperation;
}
export function register(
config: ConfigInterface,
webhookRegistry: WebhookRegistry,
) {
return async function register({
session,
}: RegisterParams): Promise<RegisterReturn> {
const log = logger(config);
log.info('Registering webhooks', {shop: session.shop});
const registerReturn: RegisterReturn = Object.keys(webhookRegistry).reduce(
(acc: RegisterReturn, 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: ConfigInterface,
session: Session,
): Promise<WebhookRegistry> {
const GraphqlClient = graphqlClientClass({config});
const client = new GraphqlClient({session});
const existingHandlers: WebhookRegistry = {};
let hasNextPage: boolean;
let endCursor: string | null = null;
do {
const query = buildCheckQuery(endCursor);
const response = await client.request<WebhookCheckResponse>(query);
response.data?.webhookSubscriptions?.edges.forEach(
(edge: WebhookCheckResponseNode) => {
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: string | null) {
return queryTemplate(TEMPLATE_GET_HANDLERS, {
END_CURSOR: JSON.stringify(endCursor),
});
}
function buildHandlerFromNode(edge: WebhookCheckResponseNode): WebhookHandler {
const endpoint = edge.node.endpoint;
let handler: WebhookHandler;
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,
}: RegisterTopicParams): Promise<RegisterResult[]> {
let registerResults: RegisterResult[] = [];
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;
}
type HandlersByKey = Record<string, WebhookHandler>;
function categorizeHandlers(
config: ConfigInterface,
existingHandlers: WebhookHandler[],
handlers: WebhookHandler[],
) {
const handlersByKey = handlers.reduce((acc: HandlersByKey, value) => {
acc[handlerIdentifier(config, value)] = value;
return acc;
}, {});
const existingHandlersByKey = existingHandlers.reduce(
(acc: HandlersByKey, value) => {
acc[handlerIdentifier(config, value)] = value;
return acc;
},
{},
);
const toCreate: HandlersByKey = {...handlersByKey};
const toUpdate: HandlersByKey = {};
const toDelete: HandlersByKey = {};
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: WebhookHandler,
arr2: WebhookHandler,
): boolean {
const includeFieldsEqual = arraysEqual(
arr1.includeFields || [],
arr2.includeFields || [],
);
const metafieldNamespacesEqual = arraysEqual(
arr1.metafieldNamespaces || [],
arr2.metafieldNamespaces || [],
);
return includeFieldsEqual && metafieldNamespacesEqual;
}
function arraysEqual(arr1: any[], arr2: any[]): boolean {
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,
}: RunMutationsParams): Promise<RegisterResult[]> {
const registerResults: RegisterResult[] = [];
for (const handler of handlers) {
registerResults.push(
await runMutation({config, client, topic, handler, operation}),
);
}
return registerResults;
}
async function runMutation({
config,
client,
topic,
handler,
operation,
}: RunMutationParams): Promise<RegisterResult> {
let registerResult: 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: ConfigInterface,
topic: string,
handler: WebhookHandler,
operation: WebhookOperation,
): string {
const params: Record<string, string> = {};
let identifier: string;
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 as any).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: WebhookHandler,
operation: WebhookOperation,
): string {
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: WebhookHandler): string {
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 as any).deliveryMethod}'`,
);
}
}
function isSuccess(
result: any,
handler: WebhookHandler,
operation: WebhookOperation,
): boolean {
const mutationName = getMutationName(handler, operation);
return Boolean(
result.data &&
result.data[mutationName] &&
result.data[mutationName].userErrors.length === 0,
);
}
export 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
}
}
}`;
export const TEMPLATE_MUTATION = `
mutation shopifyApiCreateWebhookSubscription {
{{MUTATION_NAME}}(
{{IDENTIFIER}},
{{MUTATION_PARAMS}}
) {
userErrors {
field
message
}
}
}
`;