@mojaloop/central-services-error-handling
Version:
Hapi error handling module
430 lines (395 loc) • 19.3 kB
JavaScript
/*****
License
--------------
Copyright © 2020-2025 Mojaloop Foundation
The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
Contributors
--------------
This is the official list of the Mojaloop project contributors for this file.
Names of the original copyright holders (individuals or organizations)
should be listed with a '*' in the first column. People who have
contributed from an organization can be listed under the organization
that actually holds the copyright for their contributions (see the
Mojaloop Foundation for an example). Those individuals should have
their names indented and be marked with a '-'. Email address can be added
optionally within square brackets <email>.
* Mojaloop Foundation
- Name Surname <name.surname@mojaloop.io>
* ModusBox
- Neal Donnan <neal.donnan@modusbox.com>
- Juan Correa <juan.correa@modusbox.com>
- Miguel de Barros <miguel.debarros@modusbox.com>
--------------
******/
const Enums = require('./enums')
const _ = require('lodash')
const { MojaloopFSPIOPError } = require('./errors')
/**
* See section 7.6 of "API Definition v1.0.docx". Note that some of the these
* error objects contain an httpStatusCode property that indicates the HTTP
* response code for cases where errors are returned immediately i.e. upon
* request, rather than on callback. Those error objects that do not contain
* an httpStatusCode property are expected to only be returned to callers in
* error callbacks after the initial request was accepted with a 202/200.
*/
class FSPIOPError extends MojaloopFSPIOPError {
/**
* Constructs a new error object
*
* @param cause {object} - Underlying error object or any type that represents the cause of this error
* @param message {string} - A friendly error message
* @param replyTo {string} - FSPID of the participant to whom this error is addressed
* @param apiErrorCode {object} - The MojaloopApiErrorCodes object representing the API spec error
* @param extensions {object} - API spec extensions object (if applicable)
* @param useMessageAsDescription {boolean} - Use the message as the Error description. This is useful when converting errorInformation objects into FSPIOPErrors.
*/
constructor (cause, message, replyTo, apiErrorCode, extensions, useMessageAsDescription = false) {
// Validate incoming params (if required)
// TODO: Need to clarify ML API Specification for the correct model structure for the extensionList - catering for both scenarios until this can be clarified
if (extensions && !((Array.isArray(extensions)) || (extensions.extension && Array.isArray(extensions.extension)))) throw new Error('FSPIOPError Parameter Validation Failure - extensions is not a list or does not contain an extension list.')
if (!apiErrorCode.code && !apiErrorCode.message) throw new Error('FSPIOPError Parameter Validation Failure - apiErrorCode is not valid error code enum.')
// Constructor logic goes here
const clonedExtensions = _.cloneDeep(extensions) // makes sure we make a copy.
super(cause, message, replyTo, apiErrorCode, clonedExtensions)
this._setStackFromCause(cause)
this.useMessageAsDescription = useMessageAsDescription
}
/**
* Internal only method to set the stack trace based on the set Cause.
* This can be used to serialise the error to a JSON body.
*
* @param cause {object} - Underlying error object or any type that represents the cause of this error
*/
_setStackFromCause (cause) {
let stringifiedCause
if (typeof this.cause === 'string' || this.cause instanceof String) {
stringifiedCause = this.cause
} else if (this.cause instanceof Error) {
stringifiedCause = this.cause.stack
} else {
stringifiedCause = JSON.stringify(this.cause)
}
if (stringifiedCause) this.stack = `${this.stack}\n${stringifiedCause}`
}
/**
* Returns an object that complies with the API specification for error bodies. By default object does not contain the cause extension
* This can be used to serialise the error to a JSON body
*
* @param includeCauseExtension {boolean} - Flag to specify whether or not to include cause extension at extension list
* @param truncateCause {boolean} - Flag to specify whether or not to truncate the cause string to match Mojaloop API v1.0 Spec
*
* @returns {object}
*/
toApiErrorObject ({ includeCauseExtension = false, truncateExtensions = true } = {}) {
let errorDescription = this.apiErrorCode.message
// Lets check if the message is defined, not null or empty (i.e. undefined).
if ((this.message && this.message !== 'null' && this.message.length > 0) && !this.useMessageAsDescription) {
errorDescription = `${errorDescription} - ${this.message}`.substring(0, Enums.MojaloopModelTypes.ErrorInformation.ErrorDescription.constraints.max)
} else if (this.useMessageAsDescription) { // Lets check to see if we must use the message as the errorDescription.
errorDescription = `${this.message}`.substring(0, Enums.MojaloopModelTypes.ErrorInformation.ErrorDescription.constraints.max)
}
const e = {
errorInformation: {
errorCode: this.apiErrorCode.code,
errorDescription
}
}
if (this.extensions) {
e.errorInformation.extensionList = {}
if (Array.isArray(this.extensions)) {
// TODO: Need to clarify ML API Specification for the correct model structure for the extensionList - catering for both scenarios until this can be clarified
// e.errorInformation.extensionList = _.cloneDeep(this.extensions)
e.errorInformation.extensionList.extension = _.cloneDeep(this.extensions)
} else if (this.extensions.extension && Array.isArray(this.extensions.extension)) {
e.errorInformation.extensionList.extension = _.cloneDeep(this.extensions.extension)
}
if (includeCauseExtension === true) {
const causeKeyValueFromExtensions = e.errorInformation.extensionList.extension.find(keyValue => keyValue.key === Enums.Internal.FSPIOPError.ExtensionsKeys.cause)
if (causeKeyValueFromExtensions) {
causeKeyValueFromExtensions.value = `${this.stack}\n${causeKeyValueFromExtensions.value}`
} else {
const causeKeyValue = {
key: Enums.Internal.FSPIOPError.ExtensionsKeys.cause,
value: this.stack
}
e.errorInformation.extensionList.extension.push(causeKeyValue)
}
} else if (e.errorInformation.extensionList.extension && Array.isArray(e.errorInformation.extensionList.extension)) {
_.remove(e.errorInformation.extensionList.extension, (extensionKeyValue) => {
return [Enums.Internal.FSPIOPError.ExtensionsKeys.cause, Enums.Internal.FSPIOPError.ExtensionsKeys._cause].includes(extensionKeyValue.key)
})
if (e.errorInformation.extensionList.extension.length === 0) {
delete e.errorInformation.extensionList
}
}
} else {
if (includeCauseExtension === true) {
e.errorInformation.extensionList = {
extension: [{
key: Enums.Internal.FSPIOPError.ExtensionsKeys.cause,
value: this.stack
}]
}
}
}
const hasExtension = e.errorInformation.extensionList && e.errorInformation.extensionList.extension && e.errorInformation.extensionList.extension.length
if (truncateExtensions && hasExtension) {
for (const i in e.errorInformation.extensionList.extension) {
if (e.errorInformation.extensionList.extension[i].value) {
e.errorInformation.extensionList.extension[i].value = e.errorInformation.extensionList.extension[i].value.toString().substr(0, Enums.MojaloopModelTypes.ExtensionValue.constraints.max)
}
}
}
return e
}
toString () {
return JSON.stringify(this.toFullErrorObject())
}
}
/**
* Factory method to create a new FSPIOPError.
*
* @param apiErrorCode {object} - the FSPIOP Error enum
* @param message {string} - a description of the error
* @param cause {object/string} - the original Error
* @param replyTo {string} - the FSP to notify of the error
* @param extensions {object} - additional information to associate with the error
* @param useDescriptionAsMessage {boolean} - Enables concatinations of the Message & Error Description on the
* @returns {FSPIOPError} - create the specified error, will fall back to INTERNAL_SERVER_ERROR if the apiErrorCode is undefined
*/
const createFSPIOPError = (apiErrorCode, message, cause, replyTo, extensions, useDescriptionAsMessage = false) => {
if (apiErrorCode && apiErrorCode.code && apiErrorCode.message) {
const newApiError = Object.assign({}, apiErrorCode)
let match = Enums.findFSPIOPErrorCode(apiErrorCode.code)
if (!match) {
match = Enums.findErrorType(apiErrorCode.code)
if (!match) {
throw new FSPIOPError(cause, `Factory function createFSPIOPError failed due to apiErrorCode being invalid - ${JSON.stringify(apiErrorCode)}.`, replyTo, Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR, extensions)
}
if (!newApiError.httpStatusCode) {
newApiError.httpStatusCode = match.httpStatusCode
}
} else if (!newApiError.httpStatusCode) {
newApiError.httpStatusCode = match.httpStatusCode
}
return new FSPIOPError(cause, message, replyTo, newApiError, extensions, useDescriptionAsMessage)
} else {
throw new FSPIOPError(cause, `Factory function createFSPIOPError failed due to apiErrorCode being invalid - ${JSON.stringify(apiErrorCode)}.`, replyTo, Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR, extensions)
}
}
/**
* Factory method to create an FSPIOPError from a Joi error.
*
* @param error {Error} - the Joi error
* @param cause {object/string} - an Error to use as the cause of the error if available
* @param replyTo {string} - the FSP to notify of the error if applicable
* @returns {FSPIOPError}
*/
const createFSPIOPErrorFromJoiError = (error, cause, replyTo) => {
const fspiopError = ((type) => {
switch (type) {
case 'any.required':
case 'any.empty':
return Enums.FSPIOPErrorCodes.MISSING_ELEMENT
case 'object.allowUnknown':
return Enums.FSPIOPErrorCodes.TOO_MANY_ELEMENTS
// Match any type that starts with 'string.'
case (type.match(/^string\./) || {}).input:
case 'date.format':
case 'number.integer':
case 'any.allowOnly':
case 'any.only': // added as a fix for https://github.com/mojaloop/project/issues/2013
return Enums.FSPIOPErrorCodes.MALFORMED_SYNTAX
default:
return Enums.FSPIOPErrorCodes.VALIDATION_ERROR
}
})(error.type)
const stackTrace = (cause && cause.stack)
? cause.stack
: cause
const source = (
cause &&
cause.output &&
cause.output.payload &&
cause.output.payload.validation
)
? cause.output.payload.validation.source
: undefined
const messages = {
header: `'${error.context.label}' HTTP header`,
params: `'${error.context.label}' URI path parameter`
}
// If the error was caused by a missing or invalid header or path parameter respond with
// appropriate text indicating as much
const msg = (source && messages[source])
? messages[source]
: error.message
return createFSPIOPError(fspiopError, msg, stackTrace, replyTo)
}
/**
* Factory method to create an FSPIOPError from a openapi-backend error.
*
* @param error {Object} - the openapi error
* @param replyTo {string} - the FSP to notify of the error if applicable
* @returns {FSPIOPError}
*/
const createFSPIOPErrorFromOpenapiError = (error, replyTo) => {
const fspiopError = ((type) => {
switch (type) {
case 'required':
return Enums.FSPIOPErrorCodes.MISSING_ELEMENT
case 'additionalProperties':
return Enums.FSPIOPErrorCodes.TOO_MANY_ELEMENTS
case 'type':
return Enums.FSPIOPErrorCodes.MALFORMED_SYNTAX
case 'notFound':
return Enums.FSPIOPErrorCodes.UNKNOWN_URI
case 'methodNotAllowed':
return Enums.FSPIOPErrorCodes.METHOD_NOT_ALLOWED
default:
return Enums.FSPIOPErrorCodes.VALIDATION_ERROR
}
})(error.keyword)
let message
if (error.message) {
if (error.instancePath) {
message = error.instancePath + ' ' + error.message
} else if (error.dataPath) { // replaced by instancePath, ref: https://github.com/ajv-validator/ajv/releases/tag/v8.0.0. This branch is kept here for backward compatibility.
message = error.dataPath + ' ' + error.message
} else {
message = error.message
}
} else {
if (error.instancePath) {
message = error.instancePath
} else if (error.dataPath) { // replaced by instancePath, ref: https://github.com/ajv-validator/ajv/releases/tag/v8.0.0. This branch is kept here for backward compatibility.
message = error.dataPath
} else {
message = JSON.stringify(error)
}
}
return createFSPIOPError(fspiopError, message, replyTo)
}
/**
* Convenience factory method to create a FSPIOPError Internal Server Error
*
* @param message {string} - a description of the error
* @param cause {object/string} - the original Error
* @param replyTo {string} - the FSP to notify of the error if applicable
* @param extensions {object} - additional information to associate with the error
* @returns {FSPIOPError}
*/
const createInternalServerFSPIOPError = (message, cause, replyTo, extensions) => {
return createFSPIOPError(Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR, message, cause, replyTo, extensions)
}
/**
* Factory method to reformat an FSPIOPError based on the erro being passed in.
* If the error passed in is an FSPIOPError it will be returned as is.
* If the error is any other error it will be wrapped in an FSPIOPError using the original error message
* and error stack trace.
*
* @param error the error to reformat
* @param apiErrorCode {object} - the FSPIOP Error enum, defaults to INTERNAL_SERVER_ERROR
* @param replyTo {string} - the FSP to notify of the error if applicable
* @param extensions {object} - additional information to associate with the error
* @returns {FSPIOPError}
*/
const reformatFSPIOPError = (error, apiErrorCode = Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR, replyTo, extensions) => {
if (error.constructor && error.constructor.name === FSPIOPError.name) {
return error
} else {
return createFSPIOPError(apiErrorCode, error.message, error.stack, replyTo, extensions)
}
}
/**
* Factory method to create an FSPIOPError based on the errorInformation object being passed in.
*
* @param errorInformation {object} - Mojaloop JSON ErrorInformation object
* @param cause {object/string} - the original Error
* @param replyTo {string} - the FSP to notify of the error if applicable
* @returns {FSPIOPError}
*/
const createFSPIOPErrorFromErrorInformation = (errorInformation, cause, replyTo) => {
const errorCode = {
code: errorInformation.errorCode,
message: errorInformation.errorDescription
}
return createFSPIOPError(errorCode, errorInformation.errorDescription, cause, replyTo, errorInformation.extensionList, true)
}
/**
* Factory method to create an FSPIOPError based on an errorCode (string or number).
*
* @param code {string/number} - Mojaloop Spec error code in either a string or number.
* @param message {string} - a description of the error
* @param cause {object/string} - the original Error
* @param replyTo {string} - the FSP to notify of the error if applicable
* @param extensions {object} - additional information to associate with the error
* @returns {FSPIOPError}
*/
const createFSPIOPErrorFromErrorCode = (code, message, cause, replyTo, extensions) => {
const errorCode = validateFSPIOPErrorCode(code)
return createFSPIOPError(errorCode, message, cause, replyTo, extensions)
}
/**
* Validate a code against the Mojaloop API spec, returns the enum or throws an exception if invalid.
*
* @param code {number/string/object} - Mojaloop API spec error code (four digit integer as number or string or apiErrorCode enum)
* @param throwException {boolean} - Mojaloop API spec error code (four digit integer as number or string)
* @returns apiErrorCode {object} - if valid, false if not (unless throwException is true, then an exception will be thrown instead)
* @throws {FSPIOPError} - Internal Server Error indicating that the error code is invalid.
*/
const validateFSPIOPErrorCode = (code) => {
const errorMessage = 'Validation failed due to error code being invalid'
let codeToValidate
if (typeof code === 'number' || typeof code === 'string') { // check to see if this is a normal error code represented by a number or string
codeToValidate = code
} else if (typeof code === 'object' && code.code) { // check to see if this is a apiErrorCode error
codeToValidate = code.code
}
// validate the error code
const result = Enums.findFSPIOPErrorCode(codeToValidate)
if (result) {
return result
} else {
throw createInternalServerFSPIOPError(`${errorMessage} - ${JSON.stringify(code)}.`)
}
}
/**
* Validate a code against the Mojaloop API spec, specifically custom errors, returns the incoming error code or throws an exception if invalid.
*
* @param code {number/string/object} - Mojaloop API spec error code (four digit integer as number or string or apiErrorCode enum)
* @param throwException {boolean} - Mojaloop API spec error code (four digit integer as number or string)
* @returns boolean - if valid, true, if false then an exception will be thrown instead)
* @throws {FSPIOPError} - Internal Server Error indicating that the error code is invalid.
*/
const validateFSPIOPErrorGroups = (code) => {
const errorMessage = 'Validation failed due to error code being invalid'
let codeToValidate
if (typeof code === 'number' || typeof code === 'string') { // check to see if this is a normal error code represented by a number or string
codeToValidate = code
} else if (typeof code === 'object' && code.code) { // check to see if this is a apiErrorCode error
codeToValidate = code.code
}
// validate the custom error code
const regex = /^(10|20|3[0-4]|4[0-4]|5[0-4])[0-9]{2}$/
if (regex.test(codeToValidate)) {
return true
} else {
throw createInternalServerFSPIOPError(`${errorMessage} - ${JSON.stringify(code)}.`)
}
}
module.exports = {
FSPIOPError,
createFSPIOPError,
createFSPIOPErrorFromJoiError,
createFSPIOPErrorFromOpenapiError,
createInternalServerFSPIOPError,
createFSPIOPErrorFromErrorInformation,
createFSPIOPErrorFromErrorCode,
reformatFSPIOPError,
validateFSPIOPErrorCode,
validateFSPIOPErrorGroups
}