Cache data when using the Microsoft Graph JavaScript SDK

Cache data when using the Microsoft Graph JavaScript SDK

By caching data in your collaborative JavaScript apps connected to Microsoft 365 you can significantly improve their performance and user experience. Here's how to do it.

Collaborative apps on Microsoft 365

Millions of users collaborate on Microsoft 365 every day. By connecting your apps to the data and insights they create on Microsoft 365, you can build collaborative apps that will help them work smarter and more effectively. Microsoft Graph is the API to your organization's data stored in Microsoft 365. And to help you use Microsoft Graph in your apps, Microsoft offers SDKs for the most popular programming languages.

Connect JavaScript apps to Microsoft 365

To connect your JavaScript app to Microsoft 365, use the Microsoft Graph JavaScript SDK. It offers you an easy way to execute requests to Microsoft Graph. What's more, it handles processing responses, including exceptions, for you, allowing you to focus on building your app!

To start using the Microsoft Graph JavaScript SDK, instantiate the Graph client with an authentication provider. With that, you're ready to call Microsoft Graph to get and create data in Microsoft 365.

Retrieving the same data from Microsoft 365: efficient?

The Microsoft Graph JavaScript SDK follows your instructions and retrieves the data you requested from Microsoft 365. But by default, it doesn't cache the retrieved data.

Let's say, you're building an app where you want to show a list of people in your team and files that you all worked on recently. If for each file you want to show the person who modified the file, you'll end up downloading information about the same person multiple times. Add to it the person's avatar and you see a lot of duplicate requests. So what can you do about it?

The easy solution would be to add some kind of caching. But does that mean that you'd need to change all Microsoft Graph requests you issue in your app? Not quite! The Microsoft Graph JavaScript SDK offers you a better way to do this. Check it out.

Cache middleware for the Microsoft Graph JavaScript SDK

The Microsoft Graph JavaScript SDK is built using middleware. Middleware consists of classes with different responsibilities that are applied on each request and response ran by the Graph SDK. By default, the Graph SDK has middleware for handling authentication, throttling, and executing web requests. But what's really cool, is that you can build your own middleware and plug it in the Microsoft Graph client. This allows you to run custom code on every request, without changing requests themselves - perfect for implementing caching!

Here is an example caching middleware that you can use with the Microsoft Graph JavaScript SDK to cache the data in the browser's session storage:

export function BrowserCacheMiddleware(expirationConfig) {
  this.nextMiddleware = undefined;
  this.expirationConfig = expirationConfig;

  const getHeaders = (headers) => {
    const h = {};
    for (var header of headers.entries()) {
      h[header[0]] = header[1];
    }
    return h;
  };

  const blobToDataUrl = async (blob) => {
    return new Promise((resolve, reject) => {
      var reader = new FileReader();
      reader.onload = function () {
        var dataUrl = reader.result;
        resolve(dataUrl);
      };
      reader.readAsDataURL(blob);
    });
  };

  // from https://stackoverflow.com/a/16245768
  const dataUrlToBlob = (dataUrl) => {
    const blobData = dataUrl.split(',');
    const contentType = blobData[0].replace('data:', '').replace(';base64', '');
    return b64toBlob(blobData[1], contentType);
  };

  const b64toBlob = (b64Data, contentType = '', sliceSize = 512) => {
    const byteCharacters = atob(b64Data);
    const byteArrays = [];

    for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
      const slice = byteCharacters.slice(offset, offset + sliceSize);

      const byteNumbers = new Array(slice.length);
      for (let i = 0; i < slice.length; i++) {
        byteNumbers[i] = slice.charCodeAt(i);
      }

      const byteArray = new Uint8Array(byteNumbers);
      byteArrays.push(byteArray);
    }

    const blob = new Blob(byteArrays, { type: contentType });
    return blob;
  }

  const getExpiresOn = (url) => {
    if (!this.expirationConfig) {
      return undefined;
    }

    for (var i = 0; i < this.expirationConfig.length; i++) {
      const exp = this.expirationConfig[i];
      if (url.indexOf(exp.path) > -1) {
        const expiresOn = new Date();
        expiresOn.setMinutes(expiresOn.getMinutes() + exp.expirationInMinutes);
        return expiresOn;
      }
    }

    return undefined;
  }

  return {
    execute: async (context) => {
      console.debug(`Request: ${context.request}`);

      const requestKey = btoa(context.request);
      let response = window.sessionStorage.getItem(requestKey);
      let now = new Date();
      if (response) {
        const resp = JSON.parse(response);
        const expiresOn = resp.expiresOn ? new Date(resp.expiresOn) : undefined;
        if (!expiresOn || expiresOn > now) {
          console.debug('-- from cache');

          let body;
          if (resp.headers['content-type'].indexOf('application/json') > -1) {
            body = JSON.stringify(resp.body);
          }
          else {
            body = dataUrlToBlob(resp.body);
          }
          context.response = new Response(body, resp);
          return;
        }
        else {
          console.log('-- cache expired');
        }
      }

      console.debug('-- from Graph');
      await this.nextMiddleware.execute(context);

      if (context.options.method !== 'GET' ||
        context.response.status !== 200) {
        // don't cache non-GET or failed requests
        return;
      }

      const resp = context.response.clone();

      const expiresOn = getExpiresOn(resp.url);
      // reset date to catch expiration set to 0
      now = new Date();
      // don't cache if the item already expired
      if (expiresOn <= now) {
        return;
      }

      const headers = getHeaders(resp.headers);
      let body = '';
      if (headers['content-type'].indexOf('application/json') >= 0) {
        body = await resp.json();
      }
      else {
        body = await blobToDataUrl(await resp.blob());
      }

      response = {
        url: resp.url,
        status: resp.status,
        statusText: resp.statusText,
        headers,
        body,
        expiresOn
      };
      window.sessionStorage.setItem(requestKey, JSON.stringify(response));
    },
    setNext: (next) => {
      this.nextMiddleware = next;
    }
  }
};

The entry point is in the execute function which gets the context object with the request and the response. Using the request URL, the function builds a cache key which it then uses to check if the request has been previously issued and is still in cache. If so, it returns the previously cached data. If not, it passes the request to the next middleware in the chain. When the request is completed, it checks if the response is successful and caches the retrieved data. The data is stored in the session cache and the function is clever enough to support both binary and text data.

What's also cool about this middleware is that you can configure the cache duration per request path. For example, you might want to cache files for 5 minutes but you'd want to exclude tasks to always get the latest list. You can pass the configuration that's relevant for your app when instantiating the cache middleware.

To use the cache middleware, you need to include it when instantiating the Graph client from the SDK:

import { getToken } from '../auth.js';
import { BrowserCacheMiddleware } from './BrowserCacheMiddleware.js';

// Create an authentication provider
function AuthProvider() {
  nextMiddleware: undefined;

  return {
    getAccessToken: async () => {
      // Call getToken in auth.js
      return await getToken();
    },
    setNext: (next) => {
      this.nextMiddleware = next;
    }
  }
};

const middleware = MicrosoftGraph.MiddlewareFactory.getDefaultMiddlewareChain(new AuthProvider());
middleware.unshift(new BrowserCacheMiddleware([
  // email messages might change so let's refresh them once in a while
  {
    path: '/messages',
    expirationInMinutes: 5
  },
  // calendarView queries contain current date and time with seconds so no
  // point in caching them
  {
    path: '/calendarView',
    expirationInMinutes: 0
  }
]));

// Graph client singleton
const graphClient = MicrosoftGraph.Client.initWithMiddleware({ middleware });

export default graphClient;

You must add the cache middleware to the beginning of the middleware array using middleware.unshift(). Requests and responses pass through middleware classes in the order they're added in the middleware array. If you added the cache middleware to the end of the middleware array, all requests would be issued to Graph bypassing the cache which would defy its purpose.

With this middleware in place, the Graph SDK will execute requests only the first time. If the data has been retrieved previously and the cache is still valid, the data will be served from the cache significantly speeding up your app's performance.

Before you leave

The Microsoft Graph JavaScript SDK can be used both in the browser and on the server in Node.js. The cache middleware I showed you here is meant to be used in the browser. If you wanted to use it on the server, you'd need to change two things.

First, you'd need to replace window.sessionStorage with an alternative available on the server in Node.js. Keep in mind, that it should properly handle multiple front-ends (so don't store the cache in the server's memory). Next, triple-check that you don't leak the data between sessions (eg. serving one user's data to another). That would be a true disaster.

Over to you

Adding cache to your JavaScript app using the Microsoft Graph JavaScript SDK is a great way to easily improve your app's performance. Caching the data your app retrieves from Microsoft 365 will help you decrease the number of requests to Microsoft Graph and significantly improve the user's experience.

I showed you how you can build a caching middleware yourself, but if it's something that you'd like to see the SDK support natively, please upvote the related issue in the SDK's repo. And if you have questions or ideas for improvement, leave a comment!

Comments

comments powered by Disqus