/** * ─────────────────────────────────────────────────────────────────────────────────────────────────┐ * Removes the org's self-callout for the webhook hot path. * * Previously, every Shopify webhook made the org POST an authenticated HTTP request to ITS OWN Apex * REST endpoints (SfUtility.postToSF), carrying a Bearer access token obtained from the stored refresh * token. That is the entire reason ordinary webhook traffic touched the refresh token at all — and under * the Connected App's mandatory Refresh Token Rotation, every one of those touches is a chance to lose a * rotation (a dropped connection between Salesforce minting the replacement and this org receiving it is * unavoidable over any network, however rare). Removing the network hop removes that exposure entirely * for every object type wired up below: no HTTP call, no Bearer token, no rotation, nothing to lose. * * HOW: each handler below is an existing @RestResource class whose method reads its input straight from * RestContext.request — nothing about their internals changes. This fabricates a RestRequest/RestResponse * carrying the same body/params an HTTP call would have delivered, swaps it into RestContext for the * duration of the call, and restores the ORIGINAL context before returning — the outer transaction (the * real Shopify webhook hit, itself running inside a REST context) must see its own RestContext * unaffected once this returns. This swap-and-restore is not a new trick: this codebase's own test suite * already does exactly this (see UpdateRefreshToken_Test.cls) to invoke a REST method without a real * HTTP call. * * SAFE BY CONSTRUCTION: only urlMapping suffixes explicitly listed in DIRECTLY_DISPATCHABLE are handled * here. Anything else — a mapping added later, a typo, an object type this class doesn't yet know about — * returns false, and SfUtility.postToSF falls back to the original HTTP relay unchanged. Nothing that * isn't explicitly wired up here can regress. * ────────────────────────────────────────────────────────────────────────────────────────────────── */ public with sharing class DirectWebhookDispatcher { private static final Set DIRECTLY_DISPATCHABLE = new Set{ 'UpdateCustomerFromShopify', 'DeleteCustomerFromShopify', 'UpdateProductAndVarientFromShopify', 'DeleteProductAndVariantfromShopify', 'UpdateOrderFromShopify', 'DeleteOrderFromShopify', 'UpdateCollectionAndCollectsFromShopify', 'DeleteCollectionFromShopify', 'UpdateInventoryLevelsFromShopify', 'DisconnectInventoryFormShopify', 'UpdateDraftOrderFromShopify', 'UpdateLocationFromShopify', 'DeleteLocationFromShopify' }; /** * @return true — the mapping was recognised and handled here; the caller must NOT also relay over * HTTP (that would process the same webhook twice). * false — not a mapping this class knows about; the caller should fall back to the HTTP relay. */ public static Boolean dispatch(String urlMapping, String body, String store, String objectName) { if (String.isBlank(urlMapping)) return false; String handlerName = urlMapping.contains('/') ? urlMapping.substringAfterLast('/') : urlMapping; if (!DIRECTLY_DISPATCHABLE.contains(handlerName)) return false; RestRequest savedReq = RestContext.request; RestResponse savedRes = RestContext.response; try { RestRequest innerReq = new RestRequest(); innerReq.requestBody = Blob.valueOf(body == null ? '' : body); innerReq.httpMethod = 'POST'; // RestRequest.params is a final property — already initialized to an empty map by the // constructor, so it must be populated via put(), not replaced with a new map. innerReq.params.put('store', store); innerReq.params.put('objectname', objectName); RestContext.request = innerReq; RestContext.response = new RestResponse(); switch on handlerName { when 'UpdateCustomerFromShopify' { UpdateCustomerFromShopify.updateCustomer(); } when 'DeleteCustomerFromShopify' { DeleteCustomerFromShopify.DeleteCustomer(); } when 'UpdateProductAndVarientFromShopify' { UpdateProductAndVarientFromShopify.UpdateProductandVarients(); } when 'DeleteProductAndVariantfromShopify' { DeleteProductAndVariantfromShopify.DeleteProduct(); } when 'UpdateOrderFromShopify' { UpdateOrderFromShopify.updateOrder(); } when 'DeleteOrderFromShopify' { DeleteOrderFromShopify.DeleteOrder(); } when 'UpdateCollectionAndCollectsFromShopify' { UpdateCollectionAndCollectsFromShopify.updateCollectionAndCollects(); } when 'DeleteCollectionFromShopify' { DeleteCollectionFromShopify.deleteCollection(); } when 'UpdateInventoryLevelsFromShopify' { UpdateInventoryLevelsFromShopify.UpdateIventory(); } when 'DisconnectInventoryFormShopify' { DisconnectInventoryFormShopify.DeleteVariant(); } when 'UpdateDraftOrderFromShopify' { UpdateDraftOrderFromShopify.updateOrder(); } when 'UpdateLocationFromShopify' { UpdateLocationFromShopify.updatelocation(); } when 'DeleteLocationFromShopify' { DeleteLocationFromShopify.DeleteLocation(); } when else { return false; // unreachable given the Set check above; safe fallback regardless } } return true; } catch (Exception e) { // The handler threw. Do not let that vanish: ask Shopify to redeliver rather than let the // caller believe the webhook succeeded. Set the status on the SAVED (outer, real) response — // not on RestContext.response, which at this point still refers to the inner one and is // about to be discarded. System.debug(LoggingLevel.ERROR, 'SME_DISPATCH direct dispatch failed for ' + handlerName + ' — ' + e.getTypeName() + ' | ' + e.getMessage() + ' | line ' + e.getLineNumber()); if (savedRes != null) savedRes.statusCode = 503; return true; // handled (as a failure) — the caller must not ALSO attempt the HTTP relay } finally { RestContext.request = savedReq; RestContext.response = savedRes; } } }