0
votes

I'm trying to build a micro-services web app example using GraphQL via Apollo Server Express and Passport JWT for token auth.

So far, I have 4 micro-services (User, Blog, Project, Profile) and a Gateway API where I'm stitching them together with fragments for the relationships (eg Blog.author or User.projects etc.). Everything is working well and I can perform full CRUD across the board.

Then all went to hell when I tried implementing authentication (big surprise there), though oddly enough not with implementing the auth itself, that's not the problem.

The problem is with error handling, more specifically, passing the GraphQL errors from the remote API to the Gateway for stitching. The Gateway picks up there's an error but the actual details (eg {password: 'password incorrect'}) get swallowed by the Gateway API.

USER API ERROR

{
  "errors": [
    {
      "message": "The request is invalid.",
      "type": "ValidationError",
      "state": {
        "password": [
          "password incorrect"
        ]
      },
      "path": [
        "loginUser"
      ],
      "stack": [
        ...
      ]
    }
  ],
  "data": {
    "loginUser": null
  }
}

GATEWAY API ERROR

{
  "errors": [
    {
      "message": "The request is invalid.",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "path": [
        "loginUser"
      ],
      "extensions": {
        "code": "INTERNAL_SERVER_ERROR",
        "exception": {
          "errors": [
            {
              "message": "The request is invalid.",
              "locations": [],
              "path": [
                "loginUser"
              ]
            }
          ],
          "stacktrace": [
            "Error: The request is invalid.",
            ... // stacktrace refers to node_modules/graphql- 
            tools/src/stitching
  ],
  "data": {
    "loginUser": null
  }
}

GATEWAY src/index.js import express from 'expres

s';
import { ApolloServer } from 'apollo-server-express';
// ...
import errorHandler from '../error-handling/errorHandler';

// ... app setup

const startGateway = async () => {
    const schema = await makeSchema(); // stitches schema
    const app = express();

    app.use('/graphql', (req, res, next) => {
        // passport
        // ...
    });

    const server = new ApolloServer({
        schema,
        context: ({ req }) =>  ({ authScope: req.headers.authorization }),
        // custom error handler that tries to unravel, clean and return error
        formatError: (err) => errorHandler(true)(err)
    });

    server.applyMiddleware({ app });
    app.listen({ port: PORT }, () => console.log(`\n Gateway Server ready at http://localhost:${PORT}${server.graphqlPath} \n`));
};

startGateway().catch(err => console.log(err));

GATEWAY src/remoteSchema/index.js (Where the stitching happens)

import { makeRemoteExecutableSchema, introspectSchema } from 'graphql-tools';
import { ApolloLink } from 'apollo-link';
import { setContext } from 'apollo-link-context';
import { introspectionLink, stitchingLink } from './link';


// graphql API metadata
const graphqlApis = [
    { uri: config.USER_DEV_API },
    { uri: config.BLOG_DEV_API },
    { uri: config.PROJECT_DEV_API },
    { uri: config.PROFILE_DEV_API }
];

// create executable schemas from remote GraphQL APIs
export default async () => {
    const schemas = [];

    for (const api of graphqlApis) {

        const contextLink = setContext((request, previousContext) => {
            const { authScope } = previousContext.graphqlContext;
            return {
                headers: {
                    authorization: authScope
                }
            };
        });

        // INTROSPECTION LINK
        const apiIntroSpectionLink = await introspectionLink(api.uri);

        // INTROSPECT SCHEMA
        const remoteSchema = await introspectSchema(apiIntroSpectionLink);

        // STITCHING LINK
        const apiSticthingLink = stitchingLink(api.uri);

        // MAKE REMOTE SCHEMA
        const remoteExecutableSchema = makeRemoteExecutableSchema({
            schema: remoteSchema,
            link: ApolloLink.from([contextLink, apiSticthingLink])
        });

        schemas.push(remoteExecutableSchema);
    }

    return schemas;
};

There's more to the stitching, but it would be too much here. But it stitches successfully.

USER API src/resolver

const resolvers = {
    Query: {/*...*/},
    Mutation: {
        loginUser: async (parent, user) => {
            const errorArray = [];

            // ...get the data...

            const valid = await bcrypt.compare(user.password, ifUser.password);

            if (!valid) {
                errorArray.push(validationError('password', 'password incorrect'));
                // throws a formatted error in USER API but not handled in GATEWAY
                throw new GraphQlValidationError(errorArray);
            }

            // ... return json web token if valid

        }
    }
}

USER errors.js

export class GraphQlValidationError extends GraphQLError {
    constructor(errors) {
        super('The request is invalid.');
        this.state = errors.reduce((result, error) => {
            if (Object.prototype.hasOwnProperty.call(result, error.key)) {
                result[error.key].push(error.message);
            } else {
                result[error.key] = [error.message];
            }
            return result;
        }, {});
        this.type = errorTypes.VALIDATION_ERROR;
    }
}

export const validationError = (key, message) => ({ key, message });

GATEWAY & USER errorHandler.js

import formatError from './formatError';

export default includeStack => (error) => {
    const formattedError = formatError(includeStack)(error);

    return formattedError;
};

formatError.js

import errorTypes from './errorTypes';
import unwrapErrors from './unwrapErrors';

export default shouldIncludeStack => (error) => {
    const unwrappedError = unwrapErrors(error);

    const formattedError = {
        message: unwrappedError.message || error.message,
        type: unwrappedError.type || error.type || errorTypes.ERROR,
        state: unwrappedError.state || error.state,
        detail: unwrappedError.detail || error.detail,
        path: unwrappedError.path || error.path,
    };

    if (shouldIncludeStack) {
        formattedError.stack = unwrappedError.stack || error.extensions.exception.stacktrace;
    }

    return formattedError;
};

unwrapErrors.js

export default function unwrapErrors(err) {
    if (err.extensions) {
        return unwrapErrors(err.extensions);
    }

    if (err.exception) {
        return unwrapErrors(err.exception);
    }

    if (err.errors) {
        return unwrapErrors(err.errors);
    }


    return err;
}

I apologise in advance if the code snippets are not what's needed. I'm happy to answer any questions.

Thanks in advance!

1
Can you update the question with the errorHandler function you're passing to formatError?Daniel Rearden
@DanielRearden doneJJ McGregor

1 Answers

0
votes

Ok seemed to have fixed it with help from this discussion pointing to this gist. It was a stitching error with a few unnecessary error formatting. I removed the formatError from both ApolloServer({}) and reformatted my ./src/remoteSchema/index.js to look like:

import { makeRemoteExecutableSchema, introspectSchema } from 'graphql-tools';
import { ApolloLink } from 'apollo-link';
import { HttpLink } from 'apollo-link-http';
import { setContext } from 'apollo-link-context';
import { onError } from 'apollo-link-error';
import fetch from 'node-fetch';
import config from '../../config/config';


// graphql API metadata
const graphqlApis = [
    { uri: config.USER_DEV_API },
    { uri: config.BLOG_DEV_API },
    { uri: config.PROJECT_DEV_API },
    { uri: config.PROFILE_DEV_API }
];

// create executable schemas from remote GraphQL APIs
export default async () => {
    const schemas = [];

    /*eslint-disable*/
    for (const api of graphqlApis) {

        let remoteLink = new HttpLink({ uri : api.uri, fetch });
        let remoteContext = setContext((req, previous) => {
            // if the authorization token doesn't exist, or is malformed, do not pass it upstream
            if (
                !previous.graphqlContext.authorization
                ||
                !previous.graphqlContext.authorization.match(/^Bearer /)
            ) {
                return;
            }

            return {
                headers: {
                    'Authorization': previous.graphqlContext.authorization,
                }
            }
        });

        let remoteError = onError(({ networkError, graphQLErrors }) => {
            if (graphQLErrors) {
                graphQLErrors.forEach((val) => {
                    Object.setPrototypeOf(val, Error.prototype);
                });
            }
        });
        let remoteSchema  = await introspectSchema(remoteLink);
        let remoteExecutableSchema = makeRemoteExecutableSchema({
            schema : remoteSchema,
            link : ApolloLink.from([
                remoteContext,
                remoteError,
                remoteLink
            ])
        });

        schemas.push(remoteExecutableSchema);
    }

    return schemas;
};

This was a week of pain, but from what I've seen they issue will hopefully be resolved in graphql-tools 5.0