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!
errorHandler
function you're passing toformatError
? – Daniel Rearden