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.