SyncMadeEasy/force-app/main/default/classes/DirectWebhookDispatcher.cls

123 lines
7.2 KiB
OpenEdge ABL

/**
* ─────────────────────────────────────────────────────────────────────────────────────────────────┐
* 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<String> DIRECTLY_DISPATCHABLE = new Set<String>{
'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;
}
}
}