UNPKG

@apite/shopware6-utility

Version:

Shopgate WebCheckout utility for Shopware 6 extensions

309 lines (290 loc) 9.94 kB
'use strict' const _get = require('lodash.get') const { AutoPromoNotEligibleError, InvalidCredentialsError, InactiveAccountError, PurchaseStepsError, ProductNotFoundError, ProductStockReachedError, PromoNotEligibleError, PromoNotFoundError, ThrottledError, UnknownError } = require('./errorList') const { decorateError, formatAxiosResponse } = require('./logDecorator')() /** * @param {ErrorLevel} shopwareType */ const toShopgateType = shopwareType => { switch (shopwareType) { case 20: return 'error' case 10: return 'warning' case 0: default: return 'info' } } /** * @param {EntityError} error * @return {ApiteSW6Cart.SGCartMessage} */ const toShopgateMessage = (error) => ({ type: toShopgateType(error.level), code: error.messageKey, message: error.message, messageParams: {}, translated: true }) /** * Note that this only throws if errors are present * * @param {CartErrors} errorList * @param {ApiteSW6Utility.PipelineContext} context * @throws {Error} */ const throwOnCartErrors = function (errorList, context) { Object.keys(errorList) .filter(key => errorList[key].level > 0) .forEach((key) => { context.log.info(decorateError(errorList[key])) switch (errorList[key].messageKey) { case 'product-not-found': case 'product-invalid': throw (new ProductNotFoundError().mapEntityError(errorList[key], 'ENOTFOUND')) case 'promotion-not-found': throw (new PromoNotFoundError().mapEntityError(errorList[key], 'EINVALIDCOUPON')) case 'auto-promotion-not-found': throw (new AutoPromoNotEligibleError().mapEntityError(errorList[key], 'ENOTELIGIBLE')) case 'product-stock-reached': case 'product-out-of-stock': throw (new ProductStockReachedError().mapEntityError(errorList[key], 'ESTOCKREACHED')) case 'purchase-steps-quantity': case 'min-order-quantity': throw (new PurchaseStepsError().mapEntityError(errorList[key], 'EPURCHASESTEPS')) case 'shipping-method-blocked': case 'shipping-address-blocked': case 'payment-method-blocked': // this is not a hard error, products are still added/updated break default: context.log.error(decorateError(errorList[key]), 'rest-unmapped-error') throw new UnknownError() } }) } /** * Sometimes we want to throw even on information messages * to show customer information via Error modal * * @param {CartErrors} errorList * @param {ApiteSW6Utility.PipelineContext} context * @throws {Error} */ const throwOnCartInfoErrors = function (errorList, context) { Object.keys(errorList) .filter(key => errorList[key].level === 0) .forEach((key) => { context.log.info(decorateError(errorList[key])) switch (errorList[key].messageKey) { case 'promotion-not-eligible': case 'promotion-excluded': throw (new PromoNotEligibleError().mapEntityError(errorList[key], 'ENOTELIGIBLE')) } }) throwOnCartErrors(errorList, context) } /** * @param {ShopwareError[]} messages * @param {ApiteSW6Utility.PipelineContext} context * @throws {Error} */ const throwOnMessage = function (messages, context) { messages.forEach(message => { switch (message.code) { case 'CHECKOUT__CART_LINEITEM_NOT_FOUND': context.log.info(decorateError(message), 'Could not locate line item in cart') throw new ProductNotFoundError(_get(message, 'meta.parameters.identifier', '')) case 'FRAMEWORK__INVALID_UUID': context.log.fatal(decorateError(message), 'Unexpected UID provided') throw new UnknownError() case 'FRAMEWORK__MISSING_REQUEST_PARAMETER': context.log.error(decorateError(message), 'Silenced error') // it's soft only in one case where it's a registerUrl pipeline, otherwise we need to keep track of this break case 'CHECKOUT__CUSTOMER_NOT_LOGGED_IN': context.log.debug(decorateError(message), 'Logged in SG, but contextToken is of a guest.') // a soft error when trying to log out a customer that is already using a guest token break case 'CHECKOUT__CUSTOMER_AUTH_BAD_CREDENTIALS': context.log.error(decorateError(message), 'Unauthorized request, is user/password correct?') throw new InvalidCredentialsError() case 'CHECKOUT__CUSTOMER_IS_INACTIVE': context.log.error(decorateError(message), 'Customer is not active. Needs to confirm account in email.') throw new InactiveAccountError() case 'CHECKOUT__DUPLICATE_WISHLIST_PRODUCT': context.log.info(decorateError(message), 'Duplicate product found when merging wishlist') break default: context.log.error(decorateError(message), 'Unmapped error') throw new UnknownError() } }) } /** * @param {ClientApiError|Error} error * @param {ApiteSW6Utility.PipelineContext} context * @see https://shopware.stoplight.io/docs/store-api/ZG9jOjExMTYzMDU0-error-handling * @throws {Error} */ const throwOnApiError = function (error, context) { if (!error.statusCode || !error.messages || (error.messages && error.messages.length === 0)) { context.log.error(decorateError(error), 'Not a Shopware API error thrown') throw new UnknownError() } standardizeErrorMessages(error) switch (error.statusCode) { case 400: throwOnMessage(error.messages, context) break case 401: context.log.fatal(decorateError(error), 'Unauthorized request, is your SalesChannel access token missing?') throw new UnknownError() case 403: context.log.fatal(decorateError(error), 'No authentication or wishlist is not activated in Shopware') throw new UnknownError() case 404: context.log.warn(decorateError(error), 'Product does not exists, de-sync between cached catalog & Shopware') break case 412: context.log.fatal(decorateError(error), 'Possibly SalesChannel access key is invalid.') throw new UnknownError() case 429: context.log.fatal(decorateError(error), 'Too many API requests. SW rate limiter is blocking calls.') throw new ThrottledError() case 500: default: throwOnMessage(error.messages, context) } } /** * Helps fix inconsistent SW API returned messages * * @param {ClientApiError} error */ const standardizeErrorMessages = (error) => { error.messages.forEach(message => { if (message.code === '0' && message.status === '401') { error.statusCode = 400 message.code = 'CHECKOUT__CUSTOMER_AUTH_BAD_CREDENTIALS' } else if (message.status === '404' && message.detail.startsWith('No route found')) { error.statusCode = 500 } return message }) } /** * Handle all HTTP status codes from 4xx group as an API errors, including 500 (exception for order placement issue) * 408 timeout error is handled separately * @param {number} statusCode * @returns {boolean} */ const isApiError = statusCode => { return (statusCode !== 408 && statusCode.toString().startsWith('4')) || statusCode === 500 } /** * @param {ShopwareApiError} error * @return {number} */ const extractApiErrorStatusCode = error => { return ( _get(error, 'response.status') || guessTheStatusCodeFromTheMessage(error.message) ) } /** * Sometimes the HTTP status code is not available and must be guessed from the message. * In cases like connection problems, or timeout error comes from intermediate layer (i.e. client) * @param {string} message * @return {number} */ const guessTheStatusCodeFromTheMessage = message => { // catch the specific timeout rejection from axios if (typeof message === 'string' && message.startsWith('timeout of')) { return 408 } // offline mode exception if (typeof message === 'string' && message.startsWith('Network Error')) { return 0 } // connection refused error if (typeof message === 'string' && message.startsWith('connect')) { return 0 } return 500 } /** * Extract error message * Keep the original errors[] format if 400 Bad Request for validation purposes. * 400 responses always points to the specific field/param/option, thus should be kept entirely. * * @param {ShopwareApiError} error * @returns {(string|ShopwareError[])} single message if statusCode !== 400, array of native errors otherwise */ const extractApiErrorMessage = error => { return _get(error, 'response.data.errors') || [] } /** * Extract message from AxiosError which comes from somewhere else. * @param {AxiosError} error * @returns {ShopwareError[]} */ const extractNonApiErrorMessage = error => [ { detail: error.message, status: '', code: '', title: '', meta: {}, source: {} } ] /** * Extracts and create the consistent error object * Error message depends on: * 1. type of error (API or other network layer) * 2. status code * * @param {ShopwareApiError} error * @param {ApiteSW6Utility.PipelineContext} context * @returns {Promise<ClientApiError>} */ const errorInterceptor = async (error, context) => { // Any status codes that falls outside the range of 2xx cause this function to trigger // Do something with response error const statusCode = extractApiErrorStatusCode(error) const clientApiError = { messages: isApiError(statusCode) ? extractApiErrorMessage(error) : extractNonApiErrorMessage(error), statusCode: statusCode } if (error.response) { context.log.error(formatAxiosResponse(error.response), 'rest-error-catch-all') } else { context.log.error({ message: error.message }, 'rest-non-api-error') } return Promise.reject(clientApiError) } module.exports = { errorInterceptor, throwOnApiError, throwOnCartErrors, throwOnCartInfoErrors, throwOnMessage, toShopgateType, toShopgateMessage }