apollo-error-converter

1.1.1 • Public • Published

Apollo Error Converter

Build Status Coverage Status NPM Package

A utility for greatly simplifying GraphQL Apollo Server Error handling without sacrificing core principles:

  1. Hide implementation details exposed from Errors thrown by resolvers and their underlying calls
  2. Provide logging of thrown Errors for internal administrative use and records
  3. Provide clean and useful Errors to the clients consuming the API

If you want to read more about the background and motivation for this package check out the BACKGROUND.md file.

How it works

Stop writing try/catch or rethrowing ApolloErrors throughout your Apollo Server API. Let the Errors flow! Apollo Error Converter will catch, log and convert all of your resolver-thrown Errors for you. All converted Errors adhere to the core principles you expect from a well designed API. AEC can be used with any apollo-server-x flavor.

Using the default configuration will provide you with a secure API in seconds. If you choose to customize AEC your ErrorMap and MapItems are not only simple to configure but portable and reusable across all of your GraphQL API projects!

AEC categorizes the Errors it processes as either mapped or unmapped. Mapped Errors use an ErrorMap and MapItems to define how they should be logged and converted. Unmapped Errors use a fallback MapItem for processing. Any ApolloError you manually throw from a resolver will be passed through.

The converted Errors all respect the GraphQL spec and will have the following shape:

{
  "errors": [
    {
      "path": ["failure", "path"],
      "locations": [{ "line": #, "column": # }],
      "message": "your custom message",
      "extensions": {
        "data": {
          // custom data to include
        },
        "code": "YOUR_CUSTOM_CODE"
      }
    }
  ],
}

Usage

Install using npm:

npm i apollo-error-converter

Create an instance of ApolloErrorConverter and assign it to the formatError option of the ApolloServer constructor.

const {
  ApolloErrorConverter, // required: core export 
  mapItemBases, // optional: MapItem bases of common Errors that can be extended
  extendMapItem, // optional: tool for extending MapItems with new configurations
= require("apollo-error-converter");
 
// assign it to the formatError option in ApolloError constructor
new ApolloServer({
  formatError: new ApolloErrorConverter(), // default
  formatError: new ApolloErrorConverter({ logger, fallback, errorMap }), // customize with options
});

Configuration & Behavior

Default Configuration

const { ApolloErrorConverter } = require("apollo-error-converter");
 
// assign it to the formatError option
const server = new ApolloServer({
  formatError: new ApolloErrorConverter(),
});

Behaviors for Errors handled by AEC`:

  • unmapped Errors
    • logged by default logger
      • console.error
    • converted to an ApolloError using the default fallback MapItem
      • message: "Internal Server Error"
      • code: "INTERNAL_SERVER_ERROR"
      • data: {}
  • mapped Errors
    • all Errors are considered unmapped in this configuration since there is no ErrorMap defined
  • ApolloError (or subclass) Errors
    • manually thrown from a resolver
    • no logging
    • passed through directly to the API consumer

Custom Configuration

For custom configurations take a look at the Options section below.

const { ApolloErrorConverter } = require("apollo-error-converter");
 
// assign it to the formatError option
new ApolloServer({
  formatError: new ApolloErrorConverter({ logger, fallback, errorMap }),
});

Behaviors for Errors handled by AEC:

  • unmapped Errors
    • logged using
      • options.logger
      • no logger defined: the default logger (see Options)
    • processed and converted using
      • options.fallback
      • no fallback: the default fallback (see Options)
  • mapped Errors
    • behavior dependent on MapItem configuration for the mapped Error
  • ApolloError (or subclass) Errors
    • no logging
    • passed through

Customization

AEC can have its behavior customized through the options object in its constructor. In addition there are two other exports extendMapItem and mapItemBases that can be used to quickly generate or extend MapItems.

There is a Full Example at the end of this doc that shows how an API using a Sequelize database source can configure AEC. The example includes defining an ErrorMap, MapItems, and using a winston logger. There is also a section with tips on How to create your ErrorMap.

Options

AEC constructor signature & defaults:

ApolloErrorConverter(options = {}, debug = false) -> formatError function

options.logger: used for logging Errors

default if options.logger is undefined

const defaultLogger = console.error;

custom options.logger, NOTE: winston logger users see winston usage

const options = {
  logger: false, // disables logging of unmapped Errors
  logger: true, // enables logging using the default logger
  logger: yourLogger, // enables logging using this function / method
};

options.fallback: a MapItem used for processing unmapped Errors

default if options.fallback is undefined

const defaultFallback = {
  logger: defaultLogger, // or options.logger if defined
  code: "INTERNAL_SERVER_ERROR",
  message: "Internal Server Error",
  data: {},
};

custom options.fallback, for more details on configuring a custom fallback see MapItem

const options = {
  // a fallback MapItem
  fallback: {
    code: "", // the Error code you want to use
    message: "", // the Error message you want to use
    data: {
      // additional pre-formatted data to include
    },
    data: originalError => {
      // use the original Error to format and return extra data to include
      return formattedDataObject;
    },
  },
};

errorMap: the ErrorMap used for customized Error processing

The ErrorMap associates an Error to a MapItem by the name, code or type property of the original Error object. You can reuse MapItems for more than one entry.

default if options.errorMap is undefined

const defaultErrorMap = {};

custom options.errorMap, see ErrorMap for design details and MapItem for configuring an Error mapping

const options = {
  errorMap: {
    ErrorName: MapItem, // map by error.name
    ErrorCode: MapItem, // map by error.code
    ErrorType: MapItem, // map by error.type
  },
};

multiple ErrorMaps

The most robust way to use AEC is to create ErrorMaps for each of your data sources. You can then reuse the ErrorMaps in other projects that use those data sources. You can pass an Array of ErrorMap Objects as options.errorMap which will be automatically merged.

const options = {
  errorMap: [errorMap, otherErrorMap],
};

note: the errorMap is validated during construction of AEC

  • an Error will be thrown by the first MapItem that is found to be invalid within the errorMap or merged errorMap
  • this validation prevents unexpected runtime Errors after server startup

winston logger usage

Winston logger "level methods" are bound to the objects they are assigned to. Due to the way winston is designed passing the logger method as options.logger will bind this to the options object and cause the following Error when used:

TypeError: self._addDefaultMeta is not a function

In order to pass a winston logger level method as options.logger use the following approach:

const logger = require("./logger"); // winston logger object
const { ApolloErrorConverter } = require("apollo-error-converter");
 
new ApolloServer({
  formatError: new ApolloErrorConverter({
    // assign logger.<level> as the configured logger
    logger: logger.error.bind(logger), // bind the original winston logger object
  }),
});

this behavior also applies to winston logger methods assigned in MapItem configurations

const mapItem = {
  // other MapItem options,
  logger: logger.warning.bind(logger),
};

Debug Mode

Debug mode behaves as if no formatError function exists.

  • all Errors are passed through directly from the API server to the consumer
  • to enter debug mode pass true as the second argument in the constructor
new ApolloServer({
  formatError: new ApolloErrorConverter(options, true),
});

ErrorMap

The ErrorMap is a registry for mapping Errors that should receive custom handling. It can be passed as a single object or an Array of individual ErrorMaps which are automatically merged (see Options for details).

For tips on designing your ErrorMap See How to create your ErrorMap at the end of the docs.

ErrorMaps are made up of ErrorIdentifier: MapItem mapping entries. Error Objects can be identified by their name, code or type property.

Core NodeJS Errors use the code property to distinguish themselves. However, 3rd party libraries with custom Errors use a mixture of .name, .code and type properties.

examples

const errorMap = {
  // error.name is "ValidationError"
  ValidationError: MapItem,
  // error.code is "ECONNREFUSED"
  ECONNREFUSED: MapItem,
  // error.type is "UniqueConstraint"
  UniqueConstraint: MapItem,
};

You can choose to create multiple ErrorMaps specific to each of your underlying data sources or create a single ErrorMap for your entire API. In the future I hope people share their MapItems and ErrorMaps to make this process even easier.

MapItem

The MapItem represents a configuration for processing an Error matched in the ErrorMap. You can also set AEC options.fallback to a MapItem to customize how unmapped Errors should be handled. MapItems can be reused by assigning them to multiple Error identifiers in the ErrorMap.

MapItems can be created using object literals or extended from another MapItem using the additional package export extendMapItem.

A MapItem configuration is made up of 4 options:

const mapItem = {
  code,
  data,
  logger,
  message, // required
};

REQUIRED

  • message: the client-facing message
    • appears as a top level property in the Error emitted by Apollo Server

OPTIONAL

  • logger: used for logging the original Error
    • default: does not log this Error
    • false: does not log this Error
    • true: logs using AEC options.logger
    • function: logs using this function
  • code: a code for this type of Error
    • default: 'INTERNAL_SERVER_ERROR'
    • Apollo suggested format: ALL_CAPS_AND_SNAKE_CASE
    • appears in extensions.code property of the Error emitted by Apollo Server
  • data: used for providing supplementary data to the API consumer
    • default: {} empty Object
    • an object: {}
      • preformatted data to be added to the converted Error
    • a function: (originalError) -> {}
      • a function that receives the original Error and returns a formatted data object
      • useful for extracting / shaping Error data that you want to expose to the consumer
    • appears in extensions.data property of the Error emitted by Apollo Server
  • example of a data processing function (from Full Sequelize Example)
/**
 * Extracts and shapes field errors from a Sequelize Error object
 * @param {ValidationError} validationError Sequelize ValidationError or subclass
 * @return {{ fieldName: string }} field errors object in { field: message, } form
 */
const shapeFieldErrors = validationError => {
  const { errors } = validationError;
  if (!errors) return {};
 
  const fields = errors.reduce((output, validationErrorItem) => {
    const { path, message } = validationErrorItem;
    return { ...output, [path]: message };
  }, {});
 
  return fields;
};
 
const mapItem = {
  data: shapeFieldErrors,
  code: "INVALID_FIELDS",
  message: "these fields are no good man",
};
 
const errorMap = {
  ValidationError: mapItem,
};

extendMapItem

The extendMapItem() utility creates a new MapItem from a base and extending options. The options argument is the same as that of the MapItem. If an option already exists on the base MapItem it will be overwritten by the value provided in options.

If the configuration provided in the options results in an invalid MapItem an Error will be thrown.

const mapItem = extendMapItem(mapItemToExtend, {
  // new configuration options to be applied
  code,
  data,
  logger,
  message,
});
 
// add the new MapItem to your ErrorMap

mapItemBases

As a convenience there are some MapItems provided that can be used for extension or as MapItems themselves. They each have the minimum message and code properties assigned.

const InvalidFields = {
  code: "INVALID_FIELDS",
  message: "Invalid Field Values",
};
 
const UniqueConstraint = {
  code: "UNIQUE_CONSTRAINT",
  message: "Unique Constraint Violation",
};

usage

const {
  extendMapItem,
  mapItemBases: { InvalidFields },
} = require("apollo-error-converter");
 
const mapItem = extendMapItem(InvalidFields, {
  message: "these fields are no good man",
  data: error => {
    /* extract some Error data and return an object */
  },
});
 
// mapItem has the same InvalidFields code with new message and data properties

How to create your ErrorMap

When designing your ErrorMap you need to determine which ErrorIdentifier, the name, code or type property of the Error object, to use as a mapping key. Once you know the identifier you can assign a MapItem to that entry. Here are some suggestions on determining the identifiers:

Using AEC logged Errors

  • Because unmapped Errors are automatically logged (unless you explicitly turn off logging) you can reflect on common Errors showing up in your logs and create MapItems to handle them
    • check the name, code or type property of the Error in your log files
    • determine which is suitable as an identifier and create an entry in your ErrorMap

Determining identifiers during development

  • Inspect Errors during testing / development
    • log the Error itself or error.[name, code, type] properties to determine which identifier is suitable

Determining from Library code

  • Most well-known libraries define their own custom Errors
    • do a GitHub repo search for Error which may land you in a file / module designated for custom Errors
    • see what name, code or type properties are associated with the types of Errors you want to map
  • links to some common library's custom Errors

Full Sequelize Example

Here is an example that maps Sequelize Errors and uses winston logger methods. It is all done in one file here for readability but would likely be separated in a real project.

A good idea for organization is to have each data source (db or service) used in your API export their corresponding ErrorMap. You can also centralize your ErrorMaps as a team-scoped (or public!) package that you install in your APIs. You can then merge these ErrorMaps by passing them as an Array to AEC options (see below).

const {
  ApolloServer,
  ApolloError,
  UserInputError,
} = require("apollo-server-express");
const {
  ApolloErrorConverter,
  extendMapItem,
  mapItemBases,
} = require("apollo-error-converter");
 
const logger = require("./logger"); // winston logger, must be binded
const { schema, typeDefs } = require("./schema");
 
/**
 * Extracts and shapes field errors from a Sequelize Error object
 * @param {ValidationError} validationError Sequelize ValidationError or subclass
 * @return {{ fieldName: string }} field errors object in { field: message, } form
 */
const shapeFieldErrors = validationError => {
  const { errors } = validationError;
  if (!errors) return {};
 
  const fields = errors.reduce((output, validationErrorItem) => {
    const { path, message } = validationErrorItem;
    return { ...output, [path]: message };
  }, {});
 
  return fields;
};
 
const fallback = {
  message: "Something has gone horribly wrong",
  code: "INTERNAL_SERVER_ERROR",
  data: () => ({ timestamp: Date.now() }),
};
 
const sequelizeErrorMap = {
  SequelizeValidationError: extendMapItem(mapItemBases.InvalidFields, {
    data: shapeFieldErrors,
  }),
 
  SequelizeUniqueConstraintError: extendMapItem(mapItemBases.UniqueConstraint, {
    logger: logger.db.bind(logger), // db specific logger, winston logger must be binded
  }),
};
 
const formatError = new ApolloErrorConverter({
  errorMap: sequelizeErrorMap,
  // or for multiple data source ErrorMaps
  errorMap: [sequelizeErrorMap, otherDataSourceErrorMap],
  fallback,
  logger: logger.error.bind(logger), // error specific logger, winston logger must be binded
});
 
module.exports = new ApolloServer({
  typeDefs,
  resolvers,
  formatError,
});

Behaviors for Errors received in formatError:

  • unmapped Errors
    • logged by logger.error method from a winston logger
    • converted using custom fallback
      • sets a custom message, code and data.timestamp
  • mapped Errors
    • SequelizeUniqueConstraintError
      • extends UniqueConstraint from mapItemBases
      • (from base) uses code 'UNIQUE_CONSTRAINT'
      • (from base) uses message 'Unique Constrain Violation'
      • (extended) logs original Error with logger.db method
    • SequelizeValidationError
      • extends InvalidFields from mapItemBases
      • (from base) uses code 'INVALID_FIELDS'
      • (from base) uses message 'Invalid Field Values'
      • (extended) adds field error messages extracted from the original Error by shapeFieldErrors()
      • does not log
  • ApolloError (or subclass) Errors
    • no logging
    • passed through

Package Sidebar

Install

npm i apollo-error-converter

Weekly Downloads

9

Version

1.1.1

License

MIT

Unpacked Size

85.2 kB

Total Files

37

Last publish

Collaborators

  • vampiire