@hubspot/cms-lib
Advanced tools
Comparing version 2.0.2-beta.2 to 2.1.0
@@ -0,8 +1,15 @@ | ||
const { StatusCodeError } = require('request-promise-native/errors'); | ||
const { getPortalConfig } = require('../lib/config'); | ||
const { fetchScopeData } = require('../api/localDevAuth/authenticated'); | ||
const { | ||
ApiErrorContext, | ||
logApiErrorInstance, | ||
logFileSystemErrorInstance, | ||
logServerlessFunctionApiErrorInstance, | ||
} = require('../errorHandlers'); | ||
const { LOG_LEVEL, logger } = require('../logger'); | ||
jest.mock('../lib/config'); | ||
jest.mock('../logger'); | ||
jest.mock('../api/localDevAuth/authenticated'); | ||
@@ -182,2 +189,71 @@ function createApiError(statusCode, method) { | ||
}); | ||
describe('logServerlessApiErrorInstance', () => { | ||
const portalId = 123; | ||
const logErrorSpy = jest.spyOn(logger, 'error'); | ||
beforeEach(() => { | ||
logger.clear(); | ||
getPortalConfig.mockReturnValue({ | ||
portalId, | ||
authType: 'personalaccesskey', | ||
personalAccessKey: 'let-me-in', | ||
}); | ||
}); | ||
afterEach(() => { | ||
logErrorSpy.mockReset(); | ||
}); | ||
it('logs normal errors', async () => { | ||
const message = 'Something went wrong'; | ||
const error = new Error(message); | ||
await logServerlessFunctionApiErrorInstance( | ||
portalId, | ||
error, | ||
new ApiErrorContext({ | ||
request: 'add secret', | ||
portalId, | ||
}) | ||
); | ||
expect(logErrorSpy).toHaveBeenCalledWith( | ||
`A Error has occurred. ${message}` | ||
); | ||
}); | ||
it('detects scope error with hub scope', async () => { | ||
const error = new StatusCodeError(403, { | ||
category: 'MISSING_SCOPES', | ||
}); | ||
fetchScopeData.mockImplementation(() => | ||
Promise.resolve({ | ||
portalId, | ||
userId: 456, | ||
scopeGroup: 'cms.functions.read_write', | ||
portalScopesInGroup: [ | ||
'SERVERLESS_FUNCTIONS', | ||
'SERVERLESS_FUNCTIONS_READ', | ||
'SERVERLESS_FUNCTIONS_WRITE', | ||
], | ||
userScopesInGroup: [ | ||
'SERVERLESS_FUNCTIONS', | ||
'SERVERLESS_FUNCTIONS_READ', | ||
'SERVERLESS_FUNCTIONS_WRITE', | ||
], | ||
portalUserScopeIntersection: [ | ||
'SERVERLESS_FUNCTIONS', | ||
'SERVERLESS_FUNCTIONS_READ', | ||
'SERVERLESS_FUNCTIONS_WRITE', | ||
], | ||
}) | ||
); | ||
await logServerlessFunctionApiErrorInstance( | ||
portalId, | ||
error, | ||
new ApiErrorContext({ | ||
request: 'add secret', | ||
portalId, | ||
}) | ||
); | ||
expect(logErrorSpy).toHaveBeenCalledWith( | ||
'Your access key does not allow this action. Please generate a new access key by running "hs auth personalaccesskey".' | ||
); | ||
}); | ||
}); | ||
}); |
@@ -1,5 +0,9 @@ | ||
const { cleanSchema } = require('../schema'); | ||
const { cleanSchema, writeSchemaToDisk, logSchemas } = require('../schema'); | ||
const basic = require('./fixtures/schema/basic.json'); | ||
const full = require('./fixtures/schema/full.json'); | ||
const { getCwd } = require('@hubspot/cms-lib/path'); | ||
const multiple = require('./fixtures/schema/multiple.json'); | ||
const fs = require('fs-extra'); | ||
const path = require('path'); | ||
const { logger } = require('@hubspot/cms-lib/logger'); | ||
@@ -20,2 +24,21 @@ describe('cms-lib/schema', () => { | ||
}); | ||
describe('writeSchemaToDisk()', () => { | ||
it('writes schema to disk', () => { | ||
const spy = jest.spyOn(fs, 'outputFileSync'); | ||
expect(fs.existsSync(path.resolve(getCwd(), `${basic.name}.json`))).toBe( | ||
false | ||
); | ||
writeSchemaToDisk(basic); | ||
expect(spy.mock.calls[0][1]).toMatchSnapshot(); | ||
}); | ||
}); | ||
describe('logSchemas()', () => { | ||
it('logs schemas', () => { | ||
const spy = jest.spyOn(logger, 'log'); | ||
logSchemas([basic]); | ||
expect(spy.mock.calls[0][0]).toMatchSnapshot(); | ||
}); | ||
}); | ||
}); |
@@ -1,346 +0,18 @@ | ||
const { HubSpotAuthError } = require('./lib/models/Errors'); | ||
const { logger } = require('./logger'); | ||
const { | ||
ErrorContext, | ||
isFatalError, | ||
logErrorInstance, | ||
} = require('./errorHandlers/standardErrors'); | ||
const { | ||
FileSystemErrorContext, | ||
logFileSystemErrorInstance, | ||
} = require('./errorHandlers/fileSystemErrors'); | ||
const { | ||
ApiErrorContext, | ||
logApiErrorInstance, | ||
logApiUploadErrorInstance, | ||
logServerlessFunctionApiErrorInstance, | ||
parseValidationErrors, | ||
} = require('./errorHandlers/apiErrors'); | ||
const isApiStatusCodeError = err => | ||
err.name === 'StatusCodeError' || | ||
(err.statusCode >= 100 && err.statusCode < 600); | ||
const isApiUploadValidationError = err => | ||
!!( | ||
err.statusCode === 400 && | ||
err.response && | ||
err.response.body && | ||
(err.response.body.message || err.response.body.errors) | ||
); | ||
const isSystemError = err => | ||
err.errno != null && err.code != null && err.syscall != null; | ||
const isFatalError = err => err instanceof HubSpotAuthError; | ||
const isMissingScopeError = err => | ||
err.name === 'StatusCodeError' && | ||
err.statusCode === 403 && | ||
err.error.category === 'MISSING_SCOPES'; | ||
const contactSupportString = | ||
'Please try again or visit https://help.hubspot.com/ to submit a ticket or contact HubSpot Support if the issue persists.'; | ||
const parseValidationErrors = (responseBody = {}) => { | ||
const errorMessages = []; | ||
const { errors, message } = responseBody; | ||
if (message) { | ||
errorMessages.push(message); | ||
} | ||
if (errors) { | ||
const specificErrors = errors.map(error => { | ||
let errorMessage = error.message; | ||
if (error.errorTokens && error.errorTokens.line) { | ||
errorMessage = `line ${error.errorTokens.line}: ${errorMessage}`; | ||
} | ||
return errorMessage; | ||
}); | ||
errorMessages.push(...specificErrors); | ||
} | ||
return errorMessages; | ||
}; | ||
// TODO: Make these TS interfaces | ||
class ErrorContext { | ||
constructor(props = {}) { | ||
/** @type {number} */ | ||
this.portalId = props.portalId; | ||
} | ||
} | ||
class ApiErrorContext extends ErrorContext { | ||
constructor(props = {}) { | ||
super(props); | ||
/** @type {string} */ | ||
this.request = props.request || ''; | ||
/** @type {string} */ | ||
this.payload = props.payload || ''; | ||
} | ||
} | ||
class FileSystemErrorContext extends ErrorContext { | ||
constructor(props = {}) { | ||
super(props); | ||
/** @type {string} */ | ||
this.filepath = props.filepath || ''; | ||
/** @type {boolean} */ | ||
this.read = !!props.read; | ||
/** @type {boolean} */ | ||
this.write = !!props.write; | ||
} | ||
} | ||
/** | ||
* Logs (debug) the error and context objects. | ||
* | ||
* @param {SystemError} error | ||
* @param {ErrorContext} context | ||
*/ | ||
function debugErrorAndContext(error, context) { | ||
if (error.name === 'StatusCodeError') { | ||
const { statusCode, message, response } = error; | ||
logger.debug('Error: %o', { | ||
statusCode, | ||
message, | ||
url: response.request.href, | ||
method: response.request.method, | ||
response: response.body, | ||
headers: response.headers, | ||
}); | ||
} else { | ||
logger.debug('Error: %o', error); | ||
} | ||
logger.debug('Context: %o', context); | ||
} | ||
/** | ||
* Logs a SystemError | ||
* @see {@link https://nodejs.org/api/errors.html#errors_class_systemerror} | ||
* | ||
* @param {SystemError} error | ||
* @param {ErrorContext} context | ||
*/ | ||
function logSystemError(error, context) { | ||
logger.error(`A system error has occurred: ${error.message}`); | ||
debugErrorAndContext(error, context); | ||
} | ||
/** | ||
* Logs a message for an error instance of type not asserted. | ||
* | ||
* @param {Error|SystemError|Object} error | ||
* @param {ErrorContext} context | ||
*/ | ||
function logErrorInstance(error, context) { | ||
// SystemError | ||
if (isSystemError(error)) { | ||
logSystemError(error, context); | ||
return; | ||
} | ||
if (error instanceof Error || error.message || error.reason) { | ||
// Error or Error subclass | ||
const name = error.name || 'Error'; | ||
const message = [`A ${name} has occurred.`]; | ||
[error.message, error.reason].forEach(msg => { | ||
if (msg) { | ||
message.push(msg); | ||
} | ||
}); | ||
logger.error(message.join(' ')); | ||
} else { | ||
// Unknown errors | ||
logger.error(`An unknown error has occurred.`); | ||
} | ||
debugErrorAndContext(error, context); | ||
} | ||
/** | ||
* @param {Error} error | ||
* @param {ApiErrorContext} context | ||
*/ | ||
function logValidationErrors(error, context) { | ||
const { response = {} } = error; | ||
const validationErrors = parseValidationErrors(response.body); | ||
if (validationErrors.length) { | ||
validationErrors.forEach(err => { | ||
logger.error(err); | ||
}); | ||
} | ||
debugErrorAndContext(error, context); | ||
} | ||
/** | ||
* Message segments for API messages. | ||
* | ||
* @enum {string} | ||
*/ | ||
const ApiMethodVerbs = { | ||
DEFAULT: 'request', | ||
DELETE: 'delete', | ||
GET: 'request', | ||
PATCH: 'update', | ||
POST: 'post', | ||
PUT: 'update', | ||
}; | ||
/** | ||
* Message segments for API messages. | ||
* | ||
* @enum {string} | ||
*/ | ||
const ApiMethodPrepositions = { | ||
DEFAULT: 'for', | ||
DELETE: 'of', | ||
GET: 'for', | ||
PATCH: 'to', | ||
POST: 'to', | ||
PUT: 'to', | ||
}; | ||
/** | ||
* Logs messages for an error instance resulting from API interaction. | ||
* | ||
* @param {StatusCodeError} error | ||
* @param {ApiErrorContext} context | ||
*/ | ||
function logApiStatusCodeError(error, context) { | ||
const { statusCode } = error; | ||
const { method } = error.options || {}; | ||
const isPutOrPost = method === 'PUT' || method === 'POST'; | ||
const action = ApiMethodVerbs[method] || ApiMethodVerbs.DEFAULT; | ||
const preposition = | ||
ApiMethodPrepositions[method] || ApiMethodPrepositions.DEFAULT; | ||
let messageDetail = ''; | ||
{ | ||
const request = context.request | ||
? `${action} ${preposition} "${context.request}"` | ||
: action; | ||
messageDetail = `${request} in portal ${context.portalId}`; | ||
} | ||
const errorMessage = []; | ||
if (isPutOrPost && context.payload) { | ||
errorMessage.push(`Unable to upload "${context.payload}".`); | ||
} | ||
switch (statusCode) { | ||
case 400: | ||
errorMessage.push(`The ${messageDetail} was bad.`); | ||
break; | ||
case 401: | ||
errorMessage.push(`The ${messageDetail} was unauthorized.`); | ||
break; | ||
case 403: | ||
errorMessage.push(`The ${messageDetail} was forbidden.`); | ||
break; | ||
case 404: | ||
if (context.request) { | ||
errorMessage.push( | ||
`The ${action} failed because "${context.request}" was not found in portal ${context.portalId}.` | ||
); | ||
} else { | ||
errorMessage.push(`The ${messageDetail} was not found.`); | ||
} | ||
break; | ||
case 503: | ||
errorMessage.push( | ||
`The ${messageDetail} could not be handled at this time. ${contactSupportString}` | ||
); | ||
break; | ||
default: | ||
if (statusCode >= 500 && statusCode < 600) { | ||
errorMessage.push( | ||
`The ${messageDetail} failed due to a server error. ${contactSupportString}` | ||
); | ||
} else if (statusCode >= 400 && statusCode < 500) { | ||
errorMessage.push(`The ${messageDetail} failed due to a client error.`); | ||
} else { | ||
errorMessage.push(`The ${messageDetail} failed.`); | ||
} | ||
break; | ||
} | ||
if (error.error && error.error.message) { | ||
errorMessage.push(error.error.message); | ||
} | ||
logger.error(errorMessage.join(' ')); | ||
debugErrorAndContext(error, context); | ||
} | ||
/** | ||
* Logs a message for an error instance resulting from API interaction. | ||
* | ||
* @param {Error|SystemError|Object} error | ||
* @param {ApiErrorContext} context | ||
*/ | ||
function logApiErrorInstance(error, context) { | ||
// StatusCodeError | ||
if (isApiStatusCodeError(error)) { | ||
logApiStatusCodeError(error, context); | ||
return; | ||
} | ||
logErrorInstance(error, context); | ||
} | ||
/** | ||
* Logs a message for an error instance resulting from filemapper API upload. | ||
* | ||
* @param {Error|SystemError|Object} error | ||
* @param {ApiErrorContext} context | ||
*/ | ||
function logApiUploadErrorInstance(error, context) { | ||
if (isApiUploadValidationError(error)) { | ||
logValidationErrors(error, context); | ||
return; | ||
} | ||
logApiErrorInstance(error, context); | ||
} | ||
/** | ||
* Logs a message for an error instance resulting from filesystem interaction. | ||
* | ||
* @param {Error|SystemError|Object} error | ||
* @param {FileSystemErrorContext} context | ||
*/ | ||
function logFileSystemErrorInstance(error, context) { | ||
let fileAction = ''; | ||
if (context.read) { | ||
fileAction = 'reading from'; | ||
} else if (context.write) { | ||
fileAction = 'writing to'; | ||
} else { | ||
fileAction = 'accessing'; | ||
} | ||
const filepath = context.filepath | ||
? `"${context.filepath}"` | ||
: 'a file or folder'; | ||
const message = [`An error occurred while ${fileAction} ${filepath}.`]; | ||
// Many `fs` errors will be `SystemError`s | ||
if (isSystemError(error)) { | ||
message.push(`This is the result of a system error: ${error.message}`); | ||
} | ||
logger.error(message.join(' ')); | ||
debugErrorAndContext(error, context); | ||
} | ||
/** | ||
* Logs a message for an error instance resulting from API interaction | ||
* related to serverless function. | ||
* | ||
* @param {Error|SystemError|Object} error | ||
* @param {ApiErrorContext} context | ||
*/ | ||
function logServerlessFunctionApiErrorInstance(error, scopesData, context) { | ||
if (isMissingScopeError(error) && scopesData) { | ||
const { portalScopesInGroup, userScopesInGroup } = scopesData; | ||
if (!portalScopesInGroup.length) { | ||
logger.error( | ||
'Your account does not have access to this action. Talk to an account admin to request it.' | ||
); | ||
return; | ||
} | ||
if (!portalScopesInGroup.every(s => userScopesInGroup.includes(s))) { | ||
logger.error( | ||
"You don't have access to this action. Ask an account admin to change your permissions in Users & Teams settings." | ||
); | ||
return; | ||
} else { | ||
logger.error( | ||
'Your access key does not allow this action. Please generate a new access key by running "hs auth personalaccesskey".' | ||
); | ||
return; | ||
} | ||
} | ||
// StatusCodeError | ||
if (isApiStatusCodeError(error)) { | ||
logApiStatusCodeError(error, context); | ||
return; | ||
} | ||
logErrorInstance(error, context); | ||
} | ||
module.exports = { | ||
@@ -347,0 +19,0 @@ ErrorContext, |
@@ -9,5 +9,5 @@ const fs = require('fs-extra'); | ||
const { | ||
logErrorInstance, | ||
logFileSystemErrorInstance, | ||
} = require('../errorHandlers'); | ||
} = require('../errorHandlers/fileSystemErrors'); | ||
const { logErrorInstance } = require('../errorHandlers/standardErrors'); | ||
const { getCwd } = require('../path'); | ||
@@ -14,0 +14,0 @@ const { |
@@ -18,2 +18,3 @@ const request = require('request-promise-native'); | ||
tokenInfo = { expiresAt: null, refreshToken: null, accessToken: null }, | ||
name, | ||
}, | ||
@@ -32,2 +33,3 @@ logger = console, | ||
this.refreshTokenRequest = null; | ||
this.name = name; | ||
} | ||
@@ -128,2 +130,3 @@ | ||
tokenInfo: this.tokenInfo, | ||
name: this.name, | ||
}; | ||
@@ -130,0 +133,0 @@ } |
{ | ||
"name": "@hubspot/cms-lib", | ||
"version": "2.0.2-beta.2", | ||
"version": "2.1.0", | ||
"description": "Library for working with the HubSpot CMS", | ||
@@ -34,3 +34,3 @@ "license": "Apache-2.0", | ||
}, | ||
"gitHead": "d352c878526fee39e59b59d6cda43e48840fe313" | ||
"gitHead": "31128283cf58dacc24a9a1668d3e25b7eb75c353" | ||
} |
@@ -15,4 +15,4 @@ const moment = require('moment'); | ||
} = require('./lib/constants'); | ||
const { logErrorInstance } = require('./errorHandlers/standardErrors'); | ||
const { fetchAccessToken } = require('./api/localDevAuth/unauthenticated'); | ||
const { logErrorInstance } = require('./errorHandlers'); | ||
@@ -19,0 +19,0 @@ const refreshRequests = new Map(); |
@@ -50,5 +50,11 @@ const fs = require('fs-extra'); | ||
const getResolvedPath = (dest, name) => { | ||
if (name) return path.resolve(getCwd(), dest || '', `${name}.json`); | ||
return path.resolve(getCwd(), dest || ''); | ||
}; | ||
const writeSchemaToDisk = (schema, dest) => | ||
fs.outputFileSync( | ||
path.resolve(getCwd(), dest || '', `${schema.name}.json`), | ||
getResolvedPath(dest, schema.name), | ||
prettier.format(JSON.stringify(cleanSchema(schema)), { | ||
@@ -70,4 +76,5 @@ parser: 'json', | ||
response.results.forEach(r => writeSchemaToDisk(r, dest)); | ||
logger.log(`Wrote schemas to ${path.resolve(getCwd(), dest || '')}`); | ||
} | ||
return; | ||
}; | ||
@@ -82,2 +89,3 @@ | ||
writeSchemaToDisk, | ||
getResolvedPath, | ||
listSchemas, | ||
@@ -87,2 +95,3 @@ cleanSchema, | ||
downloadSchema, | ||
logSchemas, | ||
}; |
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
Found 1 instance in 1 package
272439
84
8723
1
30