Jelajahi Sumber

Add support for errors with ES6 classes

John Lees-Miller 7 tahun lalu
induk
melakukan
ed7dc939ac

+ 111 - 7
libraries/o-error/README.md

@@ -1,12 +1,104 @@
 # overleaf-error-type
 
-Make custom error types that pass `instanceof` checks, have stack traces and support custom messages and properties.
+Make custom error types that:
+- pass `instanceof` checks,
+- have stack traces,
+- support custom messages and properties (`info`), and
+- can wrap internal errors (causes) like [VError](https://github.com/joyent/node-verror).
+
+## For ES6
+
+ES6 classes make it easy to define custom errors by subclassing `Error`. Subclassing `errorType.Error` adds a few extra helpers.
+
+### Usage
+
+#### Throw an error directly
+
+```js
+const errorType = require('overleaf-error-type')
+
+function doSomethingBad () {
+  throw new errorType.Error({
+    message: 'did something bad',
+    info: { thing: 'foo' }
+  })
+}
+doSomethingBad()
+// =>
+// { ErrorTypeError: did something bad
+//    at doSomethingBad (repl:2:9) <-- stack trace
+//    name: 'ErrorTypeError',      <-- default name
+//    info: { thing: 'foo' } }     <-- attached info
+```
+
+#### Custom error class
+
+```js
+class FooError extends errorType.Error {
+  constructor (options) {
+    super({ message: 'failed to foo', ...options })
+  }
+}
+
+function doFoo () {
+  throw new FooError({ info: { foo: 'bar' } })
+}
+doFoo()
+// =>
+// { FooError: failed to foo
+//    at doFoo (repl:2:9)      <-- stack trace
+//    name: 'FooError',        <-- correct name
+//    info: { foo: 'bar' } }   <-- attached info
+```
+
+#### Wrapping an inner error (cause)
+
+```js
+function doFoo2 () {
+  try {
+    throw new Error('bad')
+  } catch (err) {
+    throw new FooError({ info: { foo: 'bar' } }).withCause(err)
+  }
+}
+
+doFoo2()
+// =>
+// { FooError: failed to foo: bad   <-- combined message
+//     at doFoo2 (repl:5:11)        <-- stack trace
+//   name: 'FooError',              <-- correct name
+//   info: { foo: 'bar' },          <-- attached info
+//   cause:                         <-- the cause (inner error)
+//    Error: bad                    <-- inner error message
+//        at doFoo2 (repl:3:11)     <-- inner error stack trace
+//        at repl:1:1
+//        ...
+
+try {
+  doFoo2()
+} catch (err) {
+  console.log(errorType.getFullStack(err))
+}
+// =>
+// FooError: failed to foo: bad
+//     at doFoo2 (repl:5:11)
+//     at repl:2:3
+//     ...
+// caused by: Error: bad
+//     at doFoo2 (repl:3:11)
+//     at repl:2:3
+//     ...
+```
+
+## For ES5
+
+For backward compatibility, the following ES5-only interface is still supported.
 
 The approach is based mainly on https://gist.github.com/justmoon/15511f92e5216fa2624b; it just tries to DRY it up a bit.
 
-## Usage
+### Usage
 
-### Define a standalone error class
+#### Define a standalone error class
 
 ```js
 const errorType = require('overleaf-error-type')
@@ -22,7 +114,7 @@ doSomethingBad()
 //    at doSomethingBad (repl:2:9)    <-- stack trace
 ```
 
-### Define an error subclass
+#### Define an error subclass
 
 ```js
 const SubCustomError = errorType.extend(CustomError, 'SubCustomError')
@@ -37,7 +129,7 @@ try {
 }
 ```
 
-### Add custom message and/or properties
+#### Add custom message and/or properties
 
 ```js
 const UserNotFoundError = errorType.define('UserNotFoundError',
@@ -50,7 +142,7 @@ throw new UserNotFoundError(123)
 // => UserNotFoundError: User not found: 123
 ```
 
-### Add custom Error types under an existing class
+#### Add custom Error types under an existing class
 
 ```js
 class User {
@@ -72,5 +164,17 @@ User.lookup(123)
 
 ## References
 
+General:
+
+- [MDN: Error](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error)
+- [Error Handling in Node.js](https://www.joyent.com/node-js/production/design/errors)
+- [verror](https://github.com/joyent/node-verror)
+
+For ES6:
+
+- [Custom JavaScript Errors in ES6](https://medium.com/@xjamundx/custom-javascript-errors-in-es6-aa891b173f87)
+- [Custom errors, extending Error](https://javascript.info/custom-errors)
+
+For ES5:
+
 - https://gist.github.com/justmoon/15511f92e5216fa2624b
-- [MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error)

+ 110 - 2
libraries/o-error/index.js

@@ -3,11 +3,119 @@
 var util = require('util')
 
 /**
- * Make custom error types that pass `instanceof` checks, have stack traces and
- * support custom messages and properties.
+ * Make custom error types that pass `instanceof` checks, have stack traces,
+ * support custom messages and properties, and support wrapping errors (causes).
+ *
  * @module
  */
 
+//
+// For ES6+ Classes
+//
+
+/**
+ * A base class for custom errors that handles:
+ *
+ * 1. Wrapping an optional 'cause'.
+ * 2. Storing an 'info' object with additional data.
+ * 3. Setting the name to the subclass name.
+ *
+ * @extends Error
+ */
+class ErrorTypeError extends Error {
+  /**
+   * @param {string} message as for built-in Error
+   * @param {?object} info extra data to attach to the error
+   */
+  constructor ({ message, info }) {
+    super(message)
+    this.name = this.constructor.name
+    this.info = info
+  }
+
+  /**
+   * Wrap the given error, which caused this error.
+   *
+   * @param {Error} cause
+   * @return {this}
+   */
+  withCause (cause) {
+    this.cause = cause
+    if (this.message && cause.message) {
+      this.message += ': ' + cause.message
+    }
+    return this
+  }
+}
+
+/**
+ * Base class for errors with a corresponding HTTP status code.
+ *
+ * @extends ErrorTypeError
+ */
+class ErrorWithStatusCode extends ErrorTypeError {
+  /**
+   * @param {?number} statusCode an HTTP status code
+   * @param {object} options as for ErrorTypeError
+   */
+  constructor ({ statusCode, ...options }) {
+    super(options)
+    this.statusCode = statusCode || 500
+  }
+}
+exports.ErrorWithStatusCode = ErrorWithStatusCode
+
+/**
+ * Return the `info` property from `error` and recursively merge the `info`
+ * properties from the error's causes, if any.
+ *
+ * If a property is repeated, the first one in the cause chain wins.
+ *
+ * @param {?Error} error assumed not to have circular causes
+ * @return {Object}
+ */
+function getFullInfo (error) {
+  if (!error) return {}
+  const info = getFullInfo(error.cause)
+  if (typeof error.info === 'object') Object.assign(info, error.info)
+  return info
+}
+
+/**
+ * Return the `stack` property from `error` and recursively append the `stack`
+ * properties from the error's causes, if any.
+ *
+ * @param {?Error} error assumed not to have circular causes
+ * @return {string}
+ */
+function getFullStack (error) {
+  if (!error) return ''
+  const causeStack = getFullStack(error.cause)
+  if (causeStack) return (error.stack + '\ncaused by: ' + causeStack)
+  return error.stack
+}
+
+/**
+ * Is `error` or one of its causes an instance of `klass`?
+ *
+ * @param  {?Error} error assumed not to have circular causes
+ * @param  {function} klass
+ * @return {Boolean}
+ */
+function hasCauseInstanceOf (error, klass) {
+  if (!error) return false
+  return error instanceof klass || hasCauseInstanceOf(error.cause, klass)
+}
+
+exports.Error = ErrorTypeError
+exports.getFullInfo = getFullInfo
+exports.getFullStack = getFullStack
+exports.hasCauseInstanceOf = hasCauseInstanceOf
+
+//
+// For ES5
+//
+
 function extendErrorType (base, name, builder) {
   var errorConstructor = function () {
     Error.captureStackTrace && Error.captureStackTrace(this, this.constructor)

+ 123 - 0
libraries/o-error/test/error-type-class.test.js

@@ -0,0 +1,123 @@
+const errorType = require('..')
+const { expectError } = require('./support')
+
+class CustomError1 extends errorType.Error {
+  constructor (options) {
+    super({ message: 'failed to foo', ...options })
+  }
+}
+
+class CustomError2 extends errorType.Error {
+  constructor (options) {
+    super({ message: 'failed to bar', ...options })
+  }
+}
+
+describe('errorType.Error', () => {
+  it('handles a custom error type with a cause', () => {
+    function doSomethingBadInternally () {
+      throw new Error('internal error')
+    }
+
+    function doSomethingBad () {
+      try {
+        doSomethingBadInternally()
+      } catch (err) {
+        throw new CustomError1({ info: { userId: 123 } }).withCause(err)
+      }
+    }
+
+    try {
+      doSomethingBad()
+      expect.fail('should have thrown')
+    } catch (e) {
+      expectError(e, {
+        name: 'CustomError1',
+        klass: CustomError1,
+        message: 'CustomError1: failed to foo: internal error',
+        firstFrameRx: /doSomethingBad/
+      })
+      expect(errorType.getFullInfo(e)).to.deep.equal({ userId: 123 })
+      const fullStack = errorType.getFullStack(e)
+      expect(fullStack).to.match(
+        /^CustomError1: failed to foo: internal error$/m
+      )
+      expect(fullStack).to.match(
+        /^caused by: Error: internal error$/m
+      )
+    }
+  })
+
+  it('handles a custom error type with nested causes', () => {
+    function doSomethingBadInternally () {
+      throw new Error('internal error')
+    }
+
+    function doBar () {
+      try {
+        doSomethingBadInternally()
+      } catch (err) {
+        throw new CustomError2({ info: { database: 'a' } }).withCause(err)
+      }
+    }
+
+    function doFoo () {
+      try {
+        doBar()
+      } catch (err) {
+        throw new CustomError1({ info: { userId: 123 } }).withCause(err)
+      }
+    }
+
+    try {
+      doFoo()
+      expect.fail('should have thrown')
+    } catch (e) {
+      expectError(e, {
+        name: 'CustomError1',
+        klass: CustomError1,
+        message: 'CustomError1: failed to foo: failed to bar: internal error',
+        firstFrameRx: /doFoo/
+      })
+      expect(errorType.getFullInfo(e)).to.deep.equal({
+        userId: 123,
+        database: 'a'
+      })
+      const fullStack = errorType.getFullStack(e)
+      expect(fullStack).to.match(
+        /^CustomError1: failed to foo: failed to bar: internal error$/m
+      )
+      expect(fullStack).to.match(
+        /^caused by: CustomError2: failed to bar: internal error$/m
+      )
+      expect(fullStack).to.match(
+        /^caused by: Error: internal error$/m
+      )
+    }
+  })
+})
+
+describe('errorType.ErrorWithStatusCode', () => {
+  it('accepts a status code', () => {
+    function findPage () {
+      throw new errorType.ErrorWithStatusCode({
+        message: 'page not found',
+        info: { url: '/foo' },
+        statusCode: 404
+      })
+    }
+
+    try {
+      findPage()
+    } catch (e) {
+      expectError(e, {
+        name: 'ErrorWithStatusCode',
+        klass: errorType.ErrorWithStatusCode,
+        message: 'ErrorWithStatusCode: page not found',
+        firstFrameRx: /findPage/
+      })
+      expect(e.statusCode).to.equal(404)
+      expect(e.info).to.deep.equal({url: '/foo'})
+    }
+  })
+})

+ 83 - 0
libraries/o-error/test/error-type-util.test.js

@@ -0,0 +1,83 @@
+const { getFullInfo, getFullStack, hasCauseInstanceOf } = require('..')
+
+describe('errorType.getFullInfo', () => {
+  it('works on a normal error', () => {
+    const err = new Error('foo')
+    expect(getFullInfo(err)).to.deep.equal({ })
+  })
+
+  it('works on an error with .info', () => {
+    const err = new Error('foo')
+    err.info = { userId: 123 }
+    expect(getFullInfo(err)).to.deep.equal({ userId: 123 })
+  })
+
+  it('merges info from a cause chain', () => {
+    const err1 = new Error('foo')
+    const err2 = new Error('bar')
+    err1.cause = err2
+    err2.info = { userId: 123 }
+    expect(getFullInfo(err1)).to.deep.equal({ userId: 123 })
+  })
+
+  it('merges info from a cause chain with no info', () => {
+    const err1 = new Error('foo')
+    const err2 = new Error('bar')
+    err1.cause = err2
+    expect(getFullInfo(err1)).to.deep.equal({})
+  })
+
+  it('merges info from a cause chain with duplicate keys', () => {
+    const err1 = new Error('foo')
+    const err2 = new Error('bar')
+    err1.cause = err2
+    err1.info = { userId: 123 }
+    err2.info = { userId: 456 }
+    expect(getFullInfo(err1)).to.deep.equal({ userId: 123 })
+  })
+
+  it('works on an error with .info set to a string', () => {
+    const err = new Error('foo')
+    err.info = 'test'
+    expect(getFullInfo(err)).to.deep.equal({})
+  })
+})
+
+describe('errorType.getFullStack', () => {
+  it('works on a normal error', () => {
+    const err = new Error('foo')
+    const fullStack = getFullStack(err)
+    expect(fullStack).to.match(/^Error: foo$/m)
+    expect(fullStack).to.match(/^\s+at /m)
+  })
+
+  it('works on an error with a cause', () => {
+    const err1 = new Error('foo')
+    const err2 = new Error('bar')
+    err1.cause = err2
+
+    const fullStack = getFullStack(err1)
+    expect(fullStack).to.match(/^Error: foo$/m)
+    expect(fullStack).to.match(/^\s+at /m)
+    expect(fullStack).to.match(/^caused by: Error: bar$/m)
+  })
+})
+
+describe('errorType.hasCauseInstanceOf', () => {
+  it('works on a normal error', () => {
+    const err = new Error('foo')
+    expect(hasCauseInstanceOf(null, Error)).to.be.false
+    expect(hasCauseInstanceOf(err, Error)).to.be.true
+    expect(hasCauseInstanceOf(err, RangeError)).to.be.false
+  })
+
+  it('works on an error with a cause', () => {
+    const err1 = new Error('foo')
+    const err2 = new RangeError('bar')
+    err1.cause = err2
+
+    expect(hasCauseInstanceOf(err1, Error)).to.be.true
+    expect(hasCauseInstanceOf(err1, RangeError)).to.be.true
+    expect(hasCauseInstanceOf(err1, TypeError)).to.be.false
+  })
+})

+ 7 - 23
libraries/o-error/test/error-type.test.js

@@ -1,6 +1,7 @@
 'use strict'
 
 var errorType = require('..')
+const { expectError } = require('./support')
 
 describe('errorType', function () {
   it('defines a custom error type', function () {
@@ -14,29 +15,12 @@ describe('errorType', function () {
       doSomethingBad()
       expect.fail('should have thrown')
     } catch (e) {
-      // should set the name to the error's name
-      expect(e.name).to.equal('CustomError')
-
-      // should be an instance of the error type
-      expect(e instanceof CustomError).to.be.true
-
-      // should be an instance of the built-in Error type
-      expect(e instanceof Error).to.be.true
-
-      // should be recognised by util.isError
-      expect(require('util').isError(e)).to.be.true
-
-      // should have a stack trace
-      expect(e.stack).to.be.truthy
-
-      // toString should return the default error message formatting
-      expect(e.toString()).to.equal('CustomError')
-
-      // stack should start with the default error message formatting
-      expect(e.stack.split('\n')[0], 'CustomError')
-
-      // first stack frame should be the function where the error was thrown
-      expect(e.stack.split('\n')[1]).to.match(/doSomethingBad/)
+      expectError(e, {
+        name: 'CustomError',
+        klass: CustomError,
+        message: 'CustomError',
+        firstFrameRx: /doSomethingBad/
+      })
     }
   })
 

+ 26 - 0
libraries/o-error/test/support/index.js

@@ -3,3 +3,29 @@
 var chai = require('chai')
 
 global.expect = chai.expect
+
+exports.expectError = function errorTypeExpectError (e, expected) {
+  // should set the name to the error's name
+  expect(e.name).to.equal(expected.name)
+
+  // should be an instance of the error type
+  expect(e instanceof expected.klass).to.be.true
+
+  // should be an instance of the built-in Error type
+  expect(e instanceof Error).to.be.true
+
+  // should be recognised by util.isError
+  expect(require('util').isError(e)).to.be.true
+
+  // should have a stack trace
+  expect(e.stack).to.be.truthy
+
+  // toString should return the default error message formatting
+  expect(e.toString()).to.equal(expected.message)
+
+  // stack should start with the default error message formatting
+  expect(e.stack.split('\n')[0], expected.name)
+
+  // first stack frame should be the function where the error was thrown
+  expect(e.stack.split('\n')[1]).to.match(expected.firstFrameRx)
+}