@shopify/shopify-api
Version:
Shopify API Library for Node - accelerate development with support for authentication, graphql proxy, webhooks
210 lines (180 loc) • 6.1 kB
text/typescript
import {StatusCode} from '../types';
import {
abstractConvertResponse,
AdapterResponse,
isOK,
NormalizedResponse,
} from '../../runtime/http';
import {ConfigInterface} from '../base-types';
import * as ShopifyErrors from '../error';
import {logger} from '../logger';
import {
DeliveryMethod,
HttpWebhookHandlerWithCallback,
WebhookProcessParams,
WebhookRegistry,
WebhookType,
WebhookValidationErrorReason,
WebhookValidationInvalid,
WebhookValidationMissingHeaders,
WebhookValidationValid,
} from './types';
import {validateFactory} from './validate';
interface HandlerCallResult {
statusCode: StatusCode;
errorMessage?: string;
}
interface ErrorCallResult {
statusCode: StatusCode;
errorMessage: string;
}
const STATUS_TEXT_LOOKUP: Record<string, string> = {
[StatusCode.Ok]: 'OK',
[StatusCode.BadRequest]: 'Bad Request',
[StatusCode.Unauthorized]: 'Unauthorized',
[StatusCode.NotFound]: 'Not Found',
[StatusCode.InternalServerError]: 'Internal Server Error',
};
export function process(
config: ConfigInterface,
webhookRegistry: WebhookRegistry<HttpWebhookHandlerWithCallback>,
) {
return async function process({
context,
rawBody,
...adapterArgs
}: WebhookProcessParams): Promise<AdapterResponse> {
const response: NormalizedResponse = {
statusCode: StatusCode.Ok,
statusText: STATUS_TEXT_LOOKUP[StatusCode.Ok],
headers: {},
};
await logger(config).info('Receiving webhook request');
const webhookCheck = await validateFactory(config)({
rawBody,
...adapterArgs,
});
let errorMessage = 'Unknown error while handling webhook';
if (webhookCheck.valid) {
const handlerResult = await callWebhookHandlers(
config,
webhookRegistry,
webhookCheck,
rawBody,
context,
);
response.statusCode = handlerResult.statusCode;
if (!isOK(response)) {
errorMessage = handlerResult.errorMessage || errorMessage;
}
} else {
const errorResult = await handleInvalidWebhook(config, webhookCheck);
response.statusCode = errorResult.statusCode;
response.statusText = STATUS_TEXT_LOOKUP[response.statusCode];
errorMessage = errorResult.errorMessage;
}
const returnResponse = await abstractConvertResponse(response, adapterArgs);
if (!isOK(response)) {
throw new ShopifyErrors.InvalidWebhookError({
message: errorMessage,
response: returnResponse,
});
}
return Promise.resolve(returnResponse);
};
}
async function callWebhookHandlers(
config: ConfigInterface,
webhookRegistry: WebhookRegistry<HttpWebhookHandlerWithCallback>,
webhookCheck: WebhookValidationValid,
rawBody: string,
context: any,
): Promise<HandlerCallResult> {
const log = logger(config);
const {hmac: _hmac, valid: _valid, ...loggingContext} = webhookCheck;
await log.debug(
'Webhook request is valid, looking for HTTP handlers to call',
loggingContext,
);
const handlers = webhookRegistry[webhookCheck.topic] || [];
const response: HandlerCallResult = {statusCode: StatusCode.Ok};
let found = false;
for (const handler of handlers) {
if (handler.deliveryMethod !== DeliveryMethod.Http) {
continue;
}
if (!handler.callback) {
response.statusCode = StatusCode.InternalServerError;
response.errorMessage =
"Cannot call webhooks.process with a webhook handler that doesn't have a callback";
throw new ShopifyErrors.MissingWebhookCallbackError({
message: response.errorMessage,
response,
});
}
found = true;
await log.debug('Found HTTP handler, triggering it', loggingContext);
// process() only handles programmatically-registered HTTP webhooks;
// events are registered via app TOML and don't use this code path.
if (webhookCheck.webhookType !== WebhookType.Webhooks) {
throw new ShopifyErrors.InvalidWebhookError({
message: 'process() only supports traditional webhooks, not events',
response,
});
}
const {webhookId, subTopic} = webhookCheck;
try {
await handler.callback(
webhookCheck.topic,
webhookCheck.domain,
rawBody,
webhookId,
webhookCheck.apiVersion,
...(subTopic ? subTopic : ''),
context,
);
} catch (error) {
response.statusCode = StatusCode.InternalServerError;
response.errorMessage = error.message;
}
}
if (!found) {
await log.debug('No HTTP handlers found', loggingContext);
response.statusCode = StatusCode.NotFound;
response.errorMessage = `No HTTP webhooks registered for topic ${webhookCheck.topic}`;
}
return response;
}
async function handleInvalidWebhook(
config: ConfigInterface,
webhookCheck: WebhookValidationInvalid,
): Promise<ErrorCallResult> {
const response: ErrorCallResult = {
statusCode: StatusCode.InternalServerError,
errorMessage: 'Unknown error while handling webhook',
};
switch (webhookCheck.reason) {
case WebhookValidationErrorReason.MissingHeaders:
response.statusCode = StatusCode.BadRequest;
response.errorMessage = `Missing one or more of the required HTTP headers to process webhooks: [${(
webhookCheck as WebhookValidationMissingHeaders
).missingHeaders.join(', ')}]`;
break;
case WebhookValidationErrorReason.MissingBody:
response.statusCode = StatusCode.BadRequest;
response.errorMessage = 'No body was received when processing webhook';
break;
case WebhookValidationErrorReason.MissingHmac:
response.statusCode = StatusCode.BadRequest;
response.errorMessage = `Missing HMAC header in request`;
break;
case WebhookValidationErrorReason.InvalidHmac:
response.statusCode = StatusCode.Unauthorized;
response.errorMessage = `Could not validate request HMAC`;
break;
}
await logger(config).debug(
`Webhook request is invalid, returning ${response.statusCode}: ${response.errorMessage}`,
);
return response;
}