Skip to content

Instantly share code, notes, and snippets.

@jordanrios94
Last active November 12, 2018 07:23
Show Gist options
  • Save jordanrios94/53f656e538b7d17c6d7be63704bbfeb2 to your computer and use it in GitHub Desktop.
Save jordanrios94/53f656e538b7d17c6d7be63704bbfeb2 to your computer and use it in GitHub Desktop.
JS Library Overriding

Overriding library classes

Often in a JS project we import a 3rd party library in order to provide a solution to a problem we have encountered, however there are cases where a library does not provide the full specific functionality that we have to achieve when working on a project.

In production scenarios, we will include libraries for testing and database tools such as puppeteer, and mongoose.

Case Study 1 - Using Prototype

This is the most simplest solution out there to achieve and we will take mongoose for an example case study.

Every time we create a mongoose model instance, we have access to a bunch of methods, most noticeable the find method.

const blogs = await Blog.find({ _user: req.user.id });

This is a standard mongoose query which is 100% good, but what happens when someone down the line tells us to cache the query.

Well... There is a new problem, and that is mongoose does not provide us with a solution to do the following.

const blogs = await Blog.find({ _user: req.user.id }).cache();

As you can see, the cache method is not part of mongoose, however being able to take this approach is something that a developer would call, the ideal solution.

Solution

The idea behind this solution is based on our knowledge on mongoose or the library we are using in our project.

Anyone that knows mongoose will know the following quirk when using mongoose.

query.exec((err, result) => console.log(result));
// Same as...
query.then(result => console.log(result));
// Same as...
const result = await query;

So the most important thing to notice is that when we use either await or then method call, we are always calling the exec method. So what if we then modified the mogoose exec method to be something useful for us, in this case include our caching code.

services/cache.js

  • The purpose of this file would be to import mongoose and directly override and add the methods we want.
  • We modify exec to add our caching solution
  • We add cache in order to give the developer a clean solution to chain cache to the their function calls
const mongoose = require('mongoose');
const redis = require('redis');
const util = require('util');

const redisUrl = 'redis://127.0.0.1:6379';
const client = redis.createClient(redisUrl);
client.hget = util.promisify(client.hget);
const exec = mongoose.Query.prototype.exec;

mongoose.Query.prototype.cache = function(options = {}) {
  this.useCache = true;
  this.hashKey = JSON.stringify(options.key || '');

  return this;
};

mongoose.Query.prototype.exec = async function() {
  if (!this.useCache) {
    return exec.apply(this, arguments);
  }

  const key = JSON.stringify(
    Object.assign({}, this.getQuery(), {
      collection: this.mongooseCollection.name
    })
  );

  // See if we have a value for 'key' in redis
  const cacheValue = await client.hget(this.hashKey, key);

  // If we do, return that
  if (cacheValue) {
    const doc = JSON.parse(cacheValue);

    return Array.isArray(doc)
      ? doc.map(d => new this.model(d))
      : new this.model(doc);
  }

  // Otherwise, issue the query and store the result in redis
  const result = await exec.apply(this, arguments);

  client.hset(this.hashKey, key, JSON.stringify(result), 'EX', 10);

  return result;
};

module.exports = {
  clearHash(hashKey) {
    client.del(JSON.stringify(hashKey));
  }
};

Where we setup mongoose, in my case anywhere after I import mongoose in my index.js, we just make sure to import our new code as such.

require('./services/cache');

Boom!!! This will let the developer access our custom method anywhere where we use mongoose.

const blogs = await Blog.find({ _user: req.user.id }).cache();

Case Study 2 - Using Proxies

When it comes to testing, we may include libraries such as puppeteer to help us for end to end testing. There are many cases when we write test code, that we are just writing a lot of boilerplate, in this case we will use the code for login as an example.

await page.setCookie({ name: 'session', value: session });
await page.setCookie({ name: 'session.sig', value: sig });
await page.goto('localhost:3000');
await page.waitFor('a[href="/auth/logout"]');

We would prefer to write the following code and have the above executed whe login is called.

await page.login();

PS. If you are wondering what is page, here is the code. It is an object for our webpage.

const browser = await puppeteer.launch({
  headless: false
});
page = await browser.newPage();

Problem 1 - Extending a class

Extending a library can be problamatic when we cannot tell the library to use our extended class. In this case, puppeteer will have no idea about our new class that exists and how to use it.

// Third party library
class Page {
  goto() { return 'I go to page' }
  setCookie() { return 'I will set a cookie' }
}

class CustomPage extends Page {
  login() { return 'I am going to login. Put all the code to login here'; }
}

Problem 2 - I'm kind of a lazy person

So if we can't extend the class because we don't have full control, then how about wrap our instance CustomPage by passing an instance of Page to the contructor.

// Third party library
class Page {
  goto() { return 'I go to page' }
  setCookie() { return 'I will set a cookie' }
}

class CustomPage {
  constructor(page) {
    this.page = page;
  }
  
  login() { return 'I am going to login. Put all the code to login here'; }
}

Hmm... So I now have to write the following in my test cases.

const page = new Page();
const customPage = new CustomPage(page);

customPage.login();
customPage.page.goto();

As you can see, whenever I want to access a method belonging to Page, aka a Puppeteer method, I will always have to type customPage.page.setCookie instead of customPage.setCookie.

The extra code is really not ideal for a lazy person.

Solution

A solution we could use are proxies. The below code is an example on how proxies can be incredibly powerful to just access methods from a different class.

// Third party library
class Greetings {
  english() { return 'Hello' }
  spanish() { return 'Hola' }
}

class MoreGreetings {
  german() { return 'Hallo'; }
  french() { return 'Bonjour' }
}

const greetings = new Greetings();
const moreGreetings = new MoreGreetings();

const allGreetings = new Proxy(moreGreetings, {
  get: function(target, property) {
    return target[property] || greetings[property];
  }
});

// Outputs Greetings method return value
console.log(allGreetings.english());

Therefore in Puppeteer's case we would like to do the following.

// Third party library
class Page {
  goto() { return 'I go to page' }
  setCookie() { return 'I will set a cookie' }
}

class CustomPage {
  constructor(page) {
    this.page = page;
  }
  
  login() { return 'I am going to login. Put all the code to login here'; }
}

const page = new Page();
const customPage = new CustomPage(page);

const superPage = new Proxy(customPage, {
  get: function(target, property) {
    return target[property] || page[property];
  }
});

And...

Boom!!!

customPage.login();
customPage.page.goto();

Can be the following.

superPage.login();
superPage.goto();

Notice how I don't need to write .page all the time. I also left the reference there for the page, because we need it as seen later in the final version of the solution.

Improving the design pattern for the solution

We can take this approach one further, as we always create a Page and CustomPage for all the tests we run we can therefore do the following and create a method that returns our SuperPage instance.

const buildPage = () => {
  const page = new Page();
  const customPage = new CustomPage(page);

  const superPage = new Proxy(customPage, {
    get: function(target, property) {
      return target[property] || page[property];
    }
  });
  
  return superPage;
}

So anytime we want to create a new page, we can call buildPage in our beforeEach statements.

buildPage();

However the whole idea behind CustomPage class is to get involved with the proxy solution, therefore wouldn't it make sense to have the buildPage logic inside of CustomPage?

The answer is YES!

So the pattern that comes to mind is get buildPage logic and put it in a static method called build inside of CustomPage.

// Third party library
class Page {
  goto() { return 'I go to page' }
  setCookie() { return 'I will set a cookie' }
}

class CustomPage {
  static build() {
    const page = new Page();
    const customPage = new CustomPage(page);

    const superPage = new Proxy(customPage, {
      get: function(target, property) {
        return target[property] || page[property];
      }
    });
    
    return superPage;
  }

  constructor(page) {
    this.page = page;
  }
  
  login() { return 'I am going to login. Put all the code to login here'; }
}

Now when we execute our beforeEach statements or anywhere where we need a SuperPage instance, we can just call the static method.

const superPage = CustomPage.build();
superPage.login(); // The custom method we created
superPage.goto(); // Method from puppeteer
superPage.setCookie(); // Method from puppeteer

Moving the boilerplate code

If you remember the first paragraph, the idea behind this appropach was moving the boilerplate code into a manageable location. Now with the power of proxies we will have the following code.

helpers/Page.js

const puppeteer = require('puppeteer');
const sessionFactory = require('../factories/sessionFactory');
const userFactory = require('../factories/userFactory');

class CustomPage {
  static async build() {
    const browser = await puppeteer.launch({
      headless: false
    });

    const page = await browser.newPage();
    const customPage = new CustomPage(page);

    return new Proxy(customPage, {
      get: function(target, property) {
        return customPage[property] || browser[property] || page[property];
      }
    });
  }

  constructor(page) {
    this.page = page;
  }

  async login() {
    const user = await userFactory();
    const { session, sig } = sessionFactory(user);

    await this.page.setCookie({ name: 'session', value: session });
    await this.page.setCookie({ name: 'session.sig', value: sig });
    await this.page.goto('localhost:3000');
    await this.page.waitFor('a[href="/auth/logout"]');
  }
}

module.exports = CustomPage;

header.test.js

const Page = require('./helpers/Page');

let page;

beforeEach(async () => {
  page = await Page.build();
  await page.goto('localhost:3000');
});

afterEach(async () => {
  await page.close();
});

test('when signed in, shows logout button', async () => {
  await page.login();

  const text = await page.$eval('a[href="/auth/logout"]', el => el.innerHTML);

  expect(text).toEqual('Logout');
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment