As software developers and application owners, we often want to show off what we’re working on to others, especially if there’s some financial incentive to do so. Maybe we want to give a demo of our application to a potential investor or a prospective client. The problem is that staging environments and mocked data are often lifeless and devoid of the magic that makes our project special.

In an ideal world, we could show off our application using production data without violating the privacy of our users.

On a recently client project we managed to do just that by modifying our GraphQL resolvers with decorators to automatically return anonymized data. I’m very happy with the final solution, so I’d like to give you a run-through.

Setting the Scene

Imagine that we’re working on a Node.js application that uses Mongoose to model its data on the back-end. For context, imagine that our User Mongoose model looks something like this:


const userSchema = new Schema({
  name: String,
  phone: String
});

const User = mongoose.model('User', userSchema);

As we mentioned before, we’re using GraphQL to build our client-facing API. The exact GraphQL implementation we’re using doesn’t matter. Let’s just assume that we’re assembling our resolver functions into a single nested object before passing them along to our GraphQL server.

For example, a simple resolver object that supports a user query might look something like this:


const resolvers = {
  Query: {
    user: (_root, { _id }, _context) => {
      return User.findById(_id);
    }
  }
};

Our goal is to return an anonymized user object from our resolver when we detect that we’re in “demo mode”.

Updating Our Resolvers

The most obvious way of anonymizing our users when in “demo mode” would be to find every resolver that returns a User and manually modify the result before returning:


const resolvers = {
  Query: {
    user: async (_root, { _id }, context) => {
      let user = await User.findById(_id);

      // If we're in "demo mode", anonymize our user:
      if (context.user.demoMode) {
        user.name = 'Jane Doe';
        user.phone = '(555) 867-5309';
      }

      return user;
    }
  }
};

This works, but it’s a high touch, high maintenance solution. Not only do we have to comb through our codebase modifying every resolver function that returns a User type, but we also have to remember to conditionally anonymize all future resolvers that return User data.

Also, what if our anonymization logic changes? For example, what if we want anonymous users to be given the name 'Joe Schmoe' rather than 'Jane Doe'? Doh!

Thankfully, a little cleverness and a little help from Mongoose opens the doors to an elegant solution to this problem.

Anonymizing from the Model

We can improve on our previous solution by moving the anonymization logic into our User model. Let’s write an anonymize Mongoose method on our User model that scrubs the current user’s name and phone fields and returns the newly anonymized model object:


userSchema.methods.anonymize = function() {
  return _.extend({}, this, {
    name: 'Jane Doe',
    phone: '(555) 867-5309'
  });
};

We can refactor our user resolver to make use of this new method:


async (_root, { _id }, context) => {
  let user = await User.findById(_id);

  // If we're in "demo mode", anonymize our user:
  if (context.user.demoMode) {
    return user.anonymize();
  }

  return user;
}

Similarly, if we had any other GraphQL/Mongoose types we wanted to anonymize, such as a Company, we could add an anonymize function to the corresponding Mongoose model:


companySchema.methods.anonymize = function() {
  return _.extend({}, this, {
    name: 'Initech'
  });
};

And we can refactor any resolvers that return a Company GraphQL type to use our new anonymizer before returning a result:


async (_root, { _id }, context) => {
  let company = await Company.findById(_id);

  // If we're in "demo mode", anonymize our company:
  if (context.user.demoMode) {
    return company.anonymize();
  }

  return company;
}

Going Hands-off with a Decorator

Our current solution still requires that we modify every resolver in our application that returns a User or a Company. We also need to remember to conditionally anonymize any users or companies we return from resolvers we write in the future.

This is far from ideal.

Thankfully, we can automate this entire process. If you look at our two resolver functions up above, you’ll notice that the anonymization process done by each of them is nearly identical.

We anonymize our User like so:


// If we're in "demo mode", anonymize our user:
if (context.user.demoMode) {
  return user.anonymize();
}

return user;

Similarly, we anonymize our Company like so:


// If we're in "demo mode", anonymize our company:
if (context.user.demoMode) {
  return company.anonymize();
}

return company;

Because both our User and Company Mongoose models implement an identical interface in our anonymize function, the process for anonymizing their data is the same.

In theory, we could crawl through our resolvers object, looking for any resolvers that return a model with an anonymize function, and conditionally anonymize that model before returning it to the client.

Let’s write a function that does exactly that:


const anonymizeResolvers = resolvers => {
  return _.mapValues(resolvers, resolver => {
    if (_.isFunction(resolver)) {
      return decorateResolver(resolver);
    } else if (_.isObject(resolver)) {
      return anonymizeResolvers(resolver);
    } else if (_.isArray(resolver)) {
      return _.map(resolver, resolver => anonymizeResolvers(resolver));
    } else {
      return resolver;
    }
  });
};

Our new anonymizeResolvers function takes our resolvers map and maps over each of its values. If the value we’re mapping over is a function, we call a soon-to-be-written decorateResolver function that will wrap the function in our anonymization logic. Otherwise, we either recursively call anonymizeResolvers on the value if it’s an array or an object, or return it if it’s any other type of value.

Our decorateResolver function is where our anonymization magic happens:


const decorateResolver = resolver => {
  return async function(_root, _args, context) {
    let result = await resolver(...arguments);
    if (context.user.demoMode &&
        _.chain(result)
         .get('anonymize')
         .isFunction()
         .value()
    ) {
      return result.anonymize();
    } else {
      return result;
    }
  };
};

In decorateResolver we replace our original resolver function with a new function that first calls out to the original, passing through any arguments our new resolver received. Before returning the result, we check if we’re in demo mode and that the result of our call to resolver has an anonymize function. If both checks hold true, we return the anonymized result. Otherwise, we return the original result.

We can use our newly constructed anonymizeResolvers function by wrapping it around our original resolvers map before handing it off to our GraphQL server:


const resolvers = anonymizeResolvers({
  Query: {
    ...
  }
});

Now any GraphQL resolvers that return any Mongoose model with an anonymize function with return anonymized data when in demo mode, regardless of where the query lives, or when it’s written.

Final Thoughts

While I’ve been using Mongoose in this example, it’s not a requirement for implementing this type of solution. Any mechanism for “typing” objects and making them conform to an interface should get you where you need to go.

The real magic here is the automatic decoration of every resolver in our application. I’m incredibly happy with this solution, and thankful that GraphQL’s resolver architecture made it so easy to implement.

My mind is buzzing with other decorator possibilities. Authorization decorators? Logging decorators? The sky seems to be the limit. Well, the sky and the maximum call stack size.