<?php
 declare(strict_types=1); namespace Shopify\Webhooks; use Psr\Http\Client\ClientExceptionInterface; use Shopify\Exception\UninitializedContextException; use Shopify\Exception\HttpRequestException; use Shopify\Exception\MissingArgumentException; use Exception; use Shopify\Clients\Graphql; use Shopify\Clients\HttpHeaders; use Shopify\Context; use Shopify\Exception\InvalidArgumentException; use Shopify\Exception\InvalidWebhookException; use Shopify\Exception\MissingWebhookHandlerException; use Shopify\Exception\WebhookRegistrationException; use Shopify\Utils; use Shopify\Webhooks\Delivery\EventBridge; use Shopify\Webhooks\Delivery\HttpDelivery; use Shopify\Webhooks\Delivery\PubSub; final class Registry { public const DELIVERY_METHOD_HTTP = 'http'; public const DELIVERY_METHOD_EVENT_BRIDGE = 'eventbridge'; public const DELIVERY_METHOD_PUB_SUB = 'pubsub'; private static $REGISTRY = []; public static function addHandler(string $topic, Handler $handler): void { self::$REGISTRY[$topic] = $handler; } public static function getHandler(string $topic): ?Handler { return self::$REGISTRY[$topic] ?? null; } public static function register( string $path, string $topic, string $shop, string $accessToken, ?string $deliveryMethod = self::DELIVERY_METHOD_HTTP ): RegisterResponse { $method = null; switch ($deliveryMethod) { case self::DELIVERY_METHOD_EVENT_BRIDGE: $method = new EventBridge(); break; case self::DELIVERY_METHOD_PUB_SUB: $method = new PubSub(); break; case self::DELIVERY_METHOD_HTTP: $method = new HttpDelivery(); break; default: throw new InvalidArgumentException("Unrecognized delivery method '$deliveryMethod'"); } $callbackAddress = $method->getCallbackAddress($path); $client = new Graphql(Utils::sanitizeShopDomain($shop), $accessToken); list($webhookId, $mustRegister) = self::isWebhookRegistrationNeeded( $client, $topic, $callbackAddress, $method ); $registered = true; $body = null; if ($mustRegister) { $body = self::sendRegisterRequest( $client, $topic, $callbackAddress, $method, $webhookId ); $registered = $method->isSuccess($body, $webhookId); } return new RegisterResponse($registered, $body); } public static function process(array $rawHeaders, string $rawBody): ProcessResponse { if (empty($rawBody)) { throw new InvalidWebhookException("No body was received when processing webhook"); } $headers = self::parseProcessHeaders($rawHeaders); $topic = $headers->get(HttpHeaders::X_SHOPIFY_TOPIC); $shop = $headers->get(HttpHeaders::X_SHOPIFY_DOMAIN); $hmac = $headers->get(HttpHeaders::X_SHOPIFY_HMAC); self::validateProcessHmac($rawBody, $hmac); $body = json_decode($rawBody, true); $topic = self::convertTopic($topic); $handler = self::getHandler($topic); if (!$handler) { throw new MissingWebhookHandlerException("No handler was registered for topic '$topic'"); } try { $handler->handle($topic, $shop, $body); $response = new ProcessResponse(true); } catch (Exception $error) { $response = new ProcessResponse(false, $error->getMessage()); } return $response; } private static function isWebhookRegistrationNeeded( Graphql $client, string $topic, string $callbackAddress, DeliveryMethod $method ): array { $checkResponse = $client->query(data: $method->buildCheckQuery($topic)); $checkStatusCode = $checkResponse->getStatusCode(); $checkBody = $checkResponse->getDecodedBody(); if ($checkStatusCode !== 200) { $checkBodyString = json_encode($checkBody, JSON_PRETTY_PRINT); throw new WebhookRegistrationException( <<<ERROR
                Failed to check if webhook was already registered (status code $checkStatusCode):
                $checkBodyString
                ERROR
); } list($webhookId, $currentAddress) = $method->parseCheckQueryResult($checkBody); $mustRegister = ($currentAddress !== $callbackAddress); return [$webhookId, $mustRegister]; } private static function sendRegisterRequest( Graphql $client, string $topic, string $callbackAddress, DeliveryMethod $deliveryMethod, ?string $webhookId ): array { $registerResponse = $client->query( data: $deliveryMethod->buildRegisterQuery($topic, $callbackAddress, $webhookId), ); $statusCode = $registerResponse->getStatusCode(); $body = $registerResponse->getDecodedBody(); if ($statusCode !== 200) { throw new WebhookRegistrationException( <<<ERROR
                Failed to register webhook with Shopify (status code $statusCode):
                $body
                ERROR
); } return $body; } private static function parseProcessHeaders(array $rawHeaders): HttpHeaders { $headers = new HttpHeaders($rawHeaders); $missingHeaders = $headers->diff( [HttpHeaders::X_SHOPIFY_HMAC, HttpHeaders::X_SHOPIFY_TOPIC, HttpHeaders::X_SHOPIFY_DOMAIN], false, ); if (!empty($missingHeaders)) { $missingHeaders = implode(', ', $missingHeaders); throw new InvalidWebhookException( "Missing one or more of the required HTTP headers to process webhooks: [$missingHeaders]" ); } return $headers; } private static function validateProcessHmac(string $rawBody, string $hmac): void { if ($hmac !== base64_encode(hash_hmac('sha256', $rawBody, Context::$API_SECRET_KEY, true))) { throw new InvalidWebhookException("Could not validate webhook HMAC"); } } private static function convertTopic(string $topic): string { return strtoupper(str_replace(['/', '.'], '_', $topic)); } } 