1
votes

I'm using schema directives for authorization on fields. Apollo server calls the directives after the resolvers have returned. Because of this the directives don't have access to the output so when authorization fails I can't include relevant information for the user without a convoluted workaround throwing errors that ends up always returning the error data whether the query requests them or not.

I'm hoping someone understands the internals of Apollo better than I and can point out where I can insert the proper information from directives so I don't have to break the standard functionality of GraphQL.

I tried including my output in the context, but that doesn't work despite the directive having access since the data has already been returned from the resolvers and the context version isn't needed after that.

As of right now I throw a custom error in the directive with a code DIRECTIVE_ERROR and include the message I want to return to the user. In the formatResponse function I look for directive errors and filter the errors array by transferring them into data's internal errors array. I know formatResponse is not meant for modifying the content of the data, but as far as I know this is the only place left where I can access what I need. Also frustrating is the error objects within the response don't include all of the fields from the error.

type User implements Node {
  id: ID!
  email: String @requireRole(requires: "error")
}

type UserError implements Error {
  path: [String!]!
  message: String!
}

type UserPayload implements Payload {
  isSuccess: Boolean!
  errors: [UserError]
  data: User
}

type UserOutput implements Output {
  isSuccess: Boolean!
  payload: [UserPayload]
}

/**
 * All output responses should be of format:
 * {
 *  isSuccess: Boolean
 *  payload: {
 *    isSuccess: Boolean
 *    errors: {
 *      path: [String]
 *      message: String
 *    }
 *    data: [{Any}]
 *  }
 * }
 */
const formatResponse = response => {
  if (response.errors) {
    response.errors = response.errors.filter(error => {
      // if error is from a directive, extract into errors
      if (error.extensions.code === "DIRECTIVE_ERROR") {
        const path = error.path;
        const resolverKey = path[0];
        const payloadIndex = path[2];

        // protect from null
        if (response.data[resolverKey] == null) {
          response.data[resolverKey] = {
            isSuccess: false,
            payload: [{ isSuccess: false, errors: [], data: null }]
          };
        } else if (
          response.data[resolverKey].payload[payloadIndex].errors == null
        ) {
          response.data[resolverKey].payload[payloadIndex].errors = [];
        }

        // push error into data errors array
        response.data[resolverKey].payload[payloadIndex].errors.push({
          path: [path[path.length - 1]],
          message: error.message,
          __typename: "DirectiveError"
        });
      } else {
        return error;
      }
    });

    if (response.errors.length === 0) {
      return { data: response.data };
    }
  }

  return response;
};

My understanding of the order of operations in Apollo is:

resolvers return data
data filtered based on query parameters?
directives are called on the object/field where applied
data filtered based on query parameters?
formatResponse has opportunity to modify output
formatError has opportunity to modify errors
return to client

What I'd like is to not have to throw errors in the directives in order to create info to pass to the user by extracting it in formatResponse. The expected result is for the client to receive only the fields it requests, but the current method breaks that and returns the data errors and all fields whether or not the client requests them.

1

1 Answers

0
votes

You can inject it using destruct:

const { SchemaDirectiveVisitor } = require("apollo-server-express");

const { defaultFieldResolver } = require("graphql");

const _ = require("lodash");

class AuthDirective extends SchemaDirectiveVisitor {
  visitFieldDefinition(field) {
    const { resolve = defaultFieldResolver } = field;

    field.resolve = async function (parent, args, context, info) {
      // You could e.g get something from the header
      //
      // The verification below its necessary because
      // my application runs locally and on Serverless
      const authorization = _.has(context, "req")
        ? context.req.headers.authorization
        : context.headers.Authorization;

      return resolve.apply(this, [
        parent,
        args,
        {
          ...context,
          user: { authorization, name: "", id: "" }
        },
        info,
      ]);
    };
  }
}

Then on your resolver, you can access it through context.user.