diff --git a/cmds/docs/edit.js b/cmds/docs/edit.js index 6c936e09d..533ea4bb4 100644 --- a/cmds/docs/edit.js +++ b/cmds/docs/edit.js @@ -3,6 +3,7 @@ const config = require('config'); const fs = require('fs'); const editor = require('editor'); const { promisify } = require('util'); +const APIError = require('../../lib/apiError'); const writeFile = promisify(fs.writeFile); const readFile = promisify(fs.readFile); @@ -61,13 +62,7 @@ exports.run = async function (opts) { json: true, ...options, }) - .catch(err => { - if (err.statusCode === 404) { - return Promise.reject(err.error); - } - - return Promise.reject(err); - }); + .catch(err => Promise.reject(new APIError(err))); await writeFile(filename, existingDoc.body); @@ -88,13 +83,7 @@ exports.run = async function (opts) { await unlink(filename); return resolve(); }) - .catch(err => { - if (err.statusCode === 400) { - return reject(err.error); - } - - return reject(err); - }); + .catch(err => reject(new APIError(err))); }); }); }; diff --git a/cmds/docs/index.js b/cmds/docs/index.js index fdd1b24d3..d61d20397 100644 --- a/cmds/docs/index.js +++ b/cmds/docs/index.js @@ -5,6 +5,7 @@ const config = require('config'); const crypto = require('crypto'); const frontMatter = require('gray-matter'); const { promisify } = require('util'); +const APIError = require('../../lib/apiError'); const readFile = promisify(fs.readFile); @@ -49,6 +50,9 @@ exports.run = function (opts) { } const files = fs.readdirSync(folder).filter(file => file.endsWith('.md') || file.endsWith('.markdown')); + if (files.length === 0) { + return Promise.reject(new Error(`We were unable to locate Markdown files in ${folder}.`)); + } const options = { auth: { user: key }, @@ -57,14 +61,6 @@ exports.run = function (opts) { }, }; - function validationErrors(err) { - if (err.statusCode === 400) { - return Promise.reject(err.error); - } - - return Promise.reject(err); - } - function createDoc(slug, file, hash, err) { if (err.statusCode !== 404) return Promise.reject(err.error); @@ -73,13 +69,14 @@ exports.run = function (opts) { json: { slug, body: file.content, ...file.data, lastUpdatedHash: hash }, ...options, }) - .catch(validationErrors); + .catch(err => Promise.reject(new APIError(err))); } function updateDoc(slug, file, hash, existingDoc) { if (hash === existingDoc.lastUpdatedHash) { return `\`${slug}\` not updated. No changes.`; } + return request .put(`${config.host}/api/v1/docs/${slug}`, { json: Object.assign(existingDoc, { @@ -89,7 +86,7 @@ exports.run = function (opts) { }), ...options, }) - .catch(validationErrors); + .catch(err => Promise.reject(new APIError(err))); } return Promise.all( @@ -106,9 +103,7 @@ exports.run = function (opts) { ...options, }) .then(updateDoc.bind(null, slug, matter, hash), createDoc.bind(null, slug, matter, hash)) - .catch(err => { - return Promise.reject(err); - }); + .catch(err => Promise.reject(new APIError(err))); }) ); }; diff --git a/cmds/login.js b/cmds/login.js index a75b161d1..6298291c1 100644 --- a/cmds/login.js +++ b/cmds/login.js @@ -4,6 +4,7 @@ const { validate: isEmail } = require('isemail'); const { promisify } = require('util'); const read = promisify(require('read')); const configStore = require('../lib/configstore'); +const APIError = require('../lib/apiError'); const testing = process.env.NODE_ENV === 'testing'; @@ -53,14 +54,6 @@ exports.run = async function (opts) { return Promise.reject(new Error('You must provide a valid email address.')); } - function badRequest(err) { - if (err.statusCode === 400) { - return Promise.reject(err.error); - } - - return Promise.reject(err); - } - return request .post(`${config.host}/api/v1/login`, { json: { email, password, project, token }, @@ -72,5 +65,5 @@ exports.run = async function (opts) { return `Successfully logged in as ${email.green} to the ${project.blue} project.`; }) - .catch(badRequest); + .catch(err => Promise.reject(new APIError(err))); }; diff --git a/cmds/swagger.js b/cmds/swagger.js index 051baa34a..e545b0268 100644 --- a/cmds/swagger.js +++ b/cmds/swagger.js @@ -5,6 +5,7 @@ const path = require('path'); const config = require('config'); const { prompt } = require('enquirer'); const promptOpts = require('../lib/prompts'); +const APIError = require('../lib/apiError'); exports.command = 'swagger'; exports.usage = 'swagger [file] [options]'; @@ -82,7 +83,7 @@ exports.run = async function (opts) { function error(err) { try { const parsedError = JSON.parse(err.error); - return Promise.reject(new Error(parsedError.description || parsedError.error)); + return Promise.reject(new APIError(parsedError)); } catch (e) { return Promise.reject(new Error('There was an error uploading!')); } @@ -162,8 +163,8 @@ exports.run = async function (opts) { await request.post(`${config.host}/api/v1/version`, options); return newVersion; - } catch (e) { - return Promise.reject(e.error); + } catch (err) { + return Promise.reject(new APIError(err)); } } diff --git a/cmds/versions/create.js b/cmds/versions/create.js index 69c11665f..d726a5022 100644 --- a/cmds/versions/create.js +++ b/cmds/versions/create.js @@ -3,6 +3,7 @@ const config = require('config'); const semver = require('semver'); const { prompt } = require('enquirer'); const promptOpts = require('../../lib/prompts'); +const APIError = require('../../lib/apiError'); exports.command = 'versions:create'; exports.usage = 'versions:create --version= [options]'; @@ -69,7 +70,7 @@ exports.run = async function (opts) { json: true, auth: { user: key }, }) - .catch(e => Promise.reject(e.error)); + .catch(err => Promise.reject(new APIError(err))); } const versionPrompt = promptOpts.createVersionPrompt(versionList || [{}], { @@ -93,13 +94,5 @@ exports.run = async function (opts) { return request .post(`${config.host}/api/v1/version`, options) .then(() => Promise.resolve(`Version ${version} created successfully.`)) - .catch(err => { - return Promise.reject( - new Error( - err.error && err.error.description - ? err.error.description - : 'Failed to create a new version using your specified parameters.' - ) - ); - }); + .catch(err => Promise.reject(new APIError(err))); }; diff --git a/cmds/versions/delete.js b/cmds/versions/delete.js index d604abdc9..cdc9f4716 100644 --- a/cmds/versions/delete.js +++ b/cmds/versions/delete.js @@ -1,6 +1,7 @@ const request = require('request-promise-native'); const config = require('config'); const semver = require('semver'); +const APIError = require('../../lib/apiError'); exports.command = 'versions:delete'; exports.usage = 'versions:delete --version= [options]'; @@ -37,12 +38,9 @@ exports.run = async function (opts) { return request .delete(`${config.host}/api/v1/version/${version}`, { + json: true, auth: { user: key }, }) .then(() => Promise.resolve(`Version ${version} deleted successfully.`)) - .catch(err => { - return Promise.reject( - new Error(err.error && err.error.description ? err.error.description : 'Failed to delete target version.') - ); - }); + .catch(err => Promise.reject(new APIError(err))); }; diff --git a/cmds/versions/index.js b/cmds/versions/index.js index de96afbd5..5ec9b4b6d 100644 --- a/cmds/versions/index.js +++ b/cmds/versions/index.js @@ -2,6 +2,7 @@ const request = require('request-promise-native'); const Table = require('cli-table'); const config = require('config'); const versionsCreate = require('./create'); +const APIError = require('../../lib/apiError'); exports.command = 'versions'; exports.usage = 'versions [options]'; @@ -116,5 +117,6 @@ exports.run = function (opts) { } return Promise.resolve(getVersionFormatted(versions[0])); - }); + }) + .catch(err => Promise.reject(new APIError(err))); }; diff --git a/cmds/versions/update.js b/cmds/versions/update.js index ee4e7e6d6..1e8fbbf8b 100644 --- a/cmds/versions/update.js +++ b/cmds/versions/update.js @@ -3,6 +3,7 @@ const config = require('config'); const semver = require('semver'); const { prompt } = require('enquirer'); const promptOpts = require('../../lib/prompts'); +const APIError = require('../../lib/apiError'); exports.command = 'versions:update'; exports.usage = 'versions:update --version= [options]'; @@ -61,7 +62,7 @@ exports.run = async function (opts) { json: true, auth: { user: key }, }) - .catch(e => Promise.reject(e.error)); + .catch(err => Promise.reject(new APIError(err))); const promptResponse = await prompt(promptOpts.createVersionPrompt([{}], opts, foundVersion)); const options = { @@ -78,5 +79,6 @@ exports.run = async function (opts) { return request .put(`${config.host}/api/v1/version/${version}`, options) - .then(() => Promise.resolve(`Version ${version} updated successfully.`)); + .then(() => Promise.resolve(`Version ${version} updated successfully.`)) + .catch(err => Promise.reject(new APIError(err))); }; diff --git a/lib/apiError.js b/lib/apiError.js new file mode 100644 index 000000000..c022cde65 --- /dev/null +++ b/lib/apiError.js @@ -0,0 +1,35 @@ +module.exports = class extends Error { + constructor(res) { + let err; + + // Special handling to for fetch `res` arguments where `res.error` will contain our API error response. + if (typeof res === 'object') { + if ('error' in res && typeof res.error === 'object') { + err = res.error; + } else { + err = res; + } + } else { + err = res; + } + + super(err); + + if (typeof err === 'object') { + this.code = err.error; + + // If we returned help info in the API, show it otherwise don't render out multiple empty lines as we sometimes + // throw `Error('non-api custom error message')` instances and catch them with this class. + if ('help' in err) { + this.message = [err.message, '', err.help].join('\n'); + } else { + this.message = err.message; + } + + this.name = 'APIError'; + } else { + this.code = err; + this.message = err; + } + } +}; diff --git a/rdme.js b/rdme.js index 079dad55c..4738bd4e4 100755 --- a/rdme.js +++ b/rdme.js @@ -8,37 +8,17 @@ require('./cli')(process.argv.slice(2)) }) .catch(e => { if (e) { - // `err.message` is from locally thrown Error objects - // `err.error` is from remote API errors const err = e; - // If we've got a remote API error, extract its contents so we can show the user the error. - if (typeof err.error === 'object' && Object.keys(err.error).length === 3) { - err.message = err.error.error; - err.description = err.error.description; - err.errors = err.error.errors; - } - - if (!err.description && !err.errors && err.error) { + if ('message' in err) { + console.error(err.message.red); + } else { console.error( `Yikes, something went wrong! Please try again and if the problem persists, get in touch with our support team at ${ `support@readme.io`.underline }.\n`.red ); } - - if (err.message && (typeof err.statusCode === 'undefined' || err.statusCode !== 404)) - console.error(err.message.red); - - if (err.description) console.error(err.description.red); - if (err.errors) { - const errors = Object.keys(err.errors); - - console.error(`\nCause${(errors.length > 1 && 's') || ''}:`.red.bold); - errors.forEach(error => { - console.error(` ยท ${error}: ${err.errors[error]}`.red); - }); - } } return process.exit(1); diff --git a/test/cmds/docs.test.js b/test/cmds/docs.test.js index c31a50979..00409c90f 100644 --- a/test/cmds/docs.test.js +++ b/test/cmds/docs.test.js @@ -118,8 +118,10 @@ describe('rdme docs', () => { .get(`/api/v1/docs/${slug}`) .basicAuth({ user: key }) .reply(404, { - description: 'No doc found with that slug', - error: 'Not Found', + error: 'DOC_NOTFOUND', + message: `The doc with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', }); const postMock = nock(config.host, { @@ -211,12 +213,17 @@ describe('rdme docs:edit', () => { const getMock = nock(config.host) .get(`/api/v1/docs/${slug}`) - .reply(404, { error: 'Not Found', description: 'No doc found with that slug' }); + .reply(404, { + error: 'DOC_NOTFOUND', + message: `The doc with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); return docsEdit.run({ slug, key, version: '1.0.0' }).catch(err => { getMock.done(); - expect(err.error).toBe('Not Found'); - expect(err.description).toBe('No doc found with that slug'); + expect(err.code).toBe('DOC_NOTFOUND'); + expect(err.message).toContain("The doc with the slug 'no-such-doc' couldn't be found"); }); }); @@ -226,14 +233,19 @@ describe('rdme docs:edit', () => { const getMock = nock(config.host).get(`/api/v1/docs/${slug}`).reply(200, {}); - const putMock = nock(config.host).put(`/api/v1/docs/${slug}`).reply(400, { error: 'Bad Request' }); + const putMock = nock(config.host).put(`/api/v1/docs/${slug}`).reply(400, { + error: 'DOC_INVALID', + message: "We couldn't save this doc ({error})", + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); function mockEditor(filename, cb) { return cb(0); } return docsEdit.run({ slug, key, version: '1.0.0', mockEditor }).catch(err => { - expect(err.error).toBe('Bad Request'); + expect(err.code).toBe('DOC_INVALID'); getMock.done(); putMock.done(); expect(fs.existsSync(`${slug}.md`)).toBe(true); diff --git a/test/cmds/login.test.js b/test/cmds/login.test.js index fb93d2413..cc4b0bc9f 100644 --- a/test/cmds/login.test.js +++ b/test/cmds/login.test.js @@ -45,15 +45,17 @@ describe('rdme login', () => { const password = '123456'; const project = 'subdomain'; - const mock = nock(config.host).post('/api/v1/login', { email, password, project }).reply(400, { - description: 'Invalid email/password', - error: 'Bad Request', + const mock = nock(config.host).post('/api/v1/login', { email, password, project }).reply(401, { + error: 'LOGIN_INVALID', + message: 'Either your email address or password is incorrect', + suggestion: 'You can reset your password at https://dash.readme.com/forgot', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', }); return cmd.run({ email, password, project }).catch(err => { + expect(err.code).toBe('LOGIN_INVALID'); + expect(err.message).toContain('Either your email address or password is incorrect'); mock.done(); - expect(err.error).toBe('Bad Request'); - expect(err.description).toBe('Invalid email/password'); }); }); @@ -63,15 +65,17 @@ describe('rdme login', () => { const password = '123456'; const project = 'subdomain'; - const mock = nock(config.host).post('/api/v1/login', { email, password, project }).reply(400, { - description: 'You must provide a Two Factor Code', - error: 'Bad Request', + const mock = nock(config.host).post('/api/v1/login', { email, password, project }).reply(401, { + error: 'LOGIN_TWOFACTOR', + message: 'You must provide a two-factor code', + suggestion: 'You can do it via the API using `token`, or via the CLI using `rdme login --2fa`', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', }); return cmd.run({ email, password, project }).catch(err => { mock.done(); - expect(err.error).toBe('Bad Request'); - expect(err.description).toBe('You must provide a Two Factor Code'); + expect(err.code).toBe('LOGIN_TWOFACTOR'); + expect(err.message).toContain('You must provide a two-factor code'); }); }); diff --git a/test/cmds/swagger.test.js b/test/cmds/swagger.test.js index 84af0df22..03405be29 100644 --- a/test/cmds/swagger.test.js +++ b/test/cmds/swagger.test.js @@ -78,10 +78,16 @@ describe('rdme swagger', () => { .post('/api/v1/api-specification', body => body.match('form-data; name="spec"')) .delayConnection(1000) .basicAuth({ user: key }) - .reply(400); + .reply(400, { + error: 'SPEC_VERSION_NOTFOUND', + message: + "The version you specified ({version}) doesn't match any of the existing versions ({versions_list}) in ReadMe.", + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); return expect(swagger.run({ spec: './test/fixtures/swagger.json', key, version })) - .rejects.toThrow('There was an error uploading!') + .rejects.toThrow('The version you specified') .then(() => mock.done()); }); @@ -122,7 +128,10 @@ describe('rdme swagger', () => { .delayConnection(1000) .basicAuth({ user: key }) .reply(500, { - error: 'README VALIDATION ERROR "x-samples-languages" must be of type "Array"', + error: 'INTERNAL_ERROR', + message: 'Unknown error (README VALIDATION ERROR "x-samples-languages" must be of type "Array")', + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', }); return expect(swagger.run({ spec: './test/fixtures/invalid-swagger.json', key, version })) diff --git a/test/cmds/versions.test.js b/test/cmds/versions.test.js index a1f63875e..b8a2894e0 100644 --- a/test/cmds/versions.test.js +++ b/test/cmds/versions.test.js @@ -57,7 +57,7 @@ describe('rdme versions*', () => { mockRequest.done(); }); - it('should make a request to get a list of exisitng versions and return them in a raw format', async () => { + it('should make a request to get a list of existing versions and return them in a raw format', async () => { const mockRequest = nock(config.host) .get('/api/v1/version') .basicAuth({ user: key }) @@ -136,10 +136,15 @@ describe('rdme versions*', () => { is_beta: false, }); - const mockRequest = nock(config.host).post(`/api/v1/version`).basicAuth({ user: key }).reply(400); + const mockRequest = nock(config.host).post(`/api/v1/version`).basicAuth({ user: key }).reply(400, { + error: 'VERSION_EMPTY', + message: 'You need to include an x-readme-version header', + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); return createVersion.run({ key, version, fork: '0.0.5' }).catch(err => { - expect(err.message).toBe('Failed to create a new version using your specified parameters.'); + expect(err.message).toContain('You need to include an x-readme-version header'); mockRequest.done(); }); }); @@ -169,10 +174,16 @@ describe('rdme versions*', () => { }); it('should catch any request errors', () => { - const mockRequest = nock(config.host).delete(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(400); + const mockRequest = nock(config.host).delete(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(404, { + error: 'VERSION_NOTFOUND', + message: + "The version you specified ({version}) doesn't match any of the existing versions ({versions_list}) in ReadMe.", + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); return deleteVersion.run({ key, version }).catch(err => { - expect(err.message).toBe('Failed to delete target version.'); + expect(err.message).toContain('The version you specified'); mockRequest.done(); }); }); @@ -226,10 +237,15 @@ describe('rdme versions*', () => { .reply(200, { version }) .put(`/api/v1/version/${version}`) .basicAuth({ user: key }) - .reply(400); + .reply(400, { + error: 'VERSION_CANT_DEMOTE_STABLE', + message: "You can't make a stable version non-stable", + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); return updateVersion.run({ key, version }).catch(err => { - expect(err.message).toBe('400 - undefined'); + expect(err.message).toContain("You can't make a stable version non-stable"); mockRequest.done(); }); }); diff --git a/test/lib/apiError.test.js b/test/lib/apiError.test.js new file mode 100644 index 000000000..5b83125a3 --- /dev/null +++ b/test/lib/apiError.test.js @@ -0,0 +1,40 @@ +const APIError = require('../../lib/apiError'); + +const response = { + error: 'VERSION_FORK_EMPTY', + message: 'New versions need to be forked from an existing version.', + suggestion: 'You need to pass an existing version (1.0, 1.0.1) in via the `for` parameter', + docs: 'https://docs.readme.com/developers/logs/fake-metrics-uuid', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + poem: [ + 'When creating a new version', + 'A `for` value must be attached', + "You'll have to start from somewhere", + "Since versions can't start from scratch", + ], +}; + +describe('APIError', () => { + it('should handle ReadMe API errors', () => { + const error = new APIError(response); + + expect(error.code).toBe(response.error); + expect(error.message).toBe( + `New versions need to be forked from an existing version.\n\nIf you need help, email support@readme.io and mention log "fake-metrics-uuid".` + ); + }); + + it('should handle API errors from a fetch `res` object', () => { + const error = new APIError({ error: response }); + + expect(error.code).toBe(response.error); + }); + + it('should be able to handle generic non-API errors', () => { + const msg = 'i am an generic javascript error'; + const error = new APIError(msg); + + expect(error.code).toBe(msg); + expect(error.message).toBe(msg); + }); +});