spotify-web-api-js-poc

A universal JS wrapper for the Spotify Web API

Stars
7

Spotify Web API JS (Proof of Concept)

This is a proof of concept for a universal JS wrapper for the Spotify Web API. It is written using ES6 and strives for minimum size when used with bundlers that support tree-shaking.

The library is still under development and hasn't been published to npm yet.

Usage

Basic

You can import the library and the endpoints to start using them:

// easy, but you will end up with lots of unneeded code
import * as SpotifyApi from 'spotify-web-api-js-poc';
import * as SpotifyApiEndpoints from 'spotify-web-api-js-poc/endpoints';

const requestBuilder = new SpotifyApi.RequestBuilder();

SpotifyApiEndpoints.Album.getAlbum(requestBuilder, '7dNZmdcPLsUh929GLnvvsU')
  .then(result => console.log(result))
  .catch(error => console.error(error));

If you are only using a few of the endpoints, which is the most usual case, import only what you need:

// way better now!
import { RequestBuilder } from 'spotify-web-api-js-poc';
import { getAlbum } from 'spotify-web-api-js-poc/endpoints/album';

const requestBuilder = new RequestBuilder();

getAlbum(requestBuilder, '7dNZmdcPLsUh929GLnvvsU')
  .then(result => console.log(result))
  .catch(error => console.error(error));

Advanced

In some cases you might want to run some custom logic, like retrying a request if the token has expired. You can create your own Request to handle this:

import { RequestBuilder, Request } from 'spotify-web-api-js-poc';
import { getAlbum } from 'spotify-web-api-js-poc/endpoints/album';

class MyRequest extends Request {
  send() {
    return new Promise((resolve, reject) =>
      super.send()
        .then(d => resolve(d))
        .catch(e => {
          if (e.statusCode === 409) {
            // refresh...
            // retry...
            // either resolve or reject the promise
          }
        })
      );
  }
}

const requestBuilder = new RequestBuilder(MyRequest);
getAlbum(requestBuilder, '7dNZmdcPLsUh929GLnvvsU')
  .then(result => console.log(result))
  .catch(error => console.error(error));

Instructions to develop

Clone the package and install the npm dependencies with npm i.

Generating the output

If you install webpack globally, run webpack to generate the bundle for the browser.

Running tests

Run npm test

Linting

Run npm run lint

Why

I have previously worked on a client-side JS wrapper and a Node.JS one. They work great, but they have some limitations in their current shape:

All the helper functions are kept in the same file

If you have a look at this file or this other one you'll see that they are a long list of similar functions without a clear grouping. This is quite bad for maintainability since it makes finding a specific bit of code difficult.

The test files covering them are equally convoluted and difficult to reason about.

No way to inject code before or after a request

In some cases one might want to run the same bit of code before or after a request. For instance, it might be useful to add a throttle function before making a request, or some logic to refresh an expired token and retry the request if the current request fails.

There wrappers encapsulate the request object for the good, but prevent these custom additions.

No way to get rid off functions not used

Even if you just want to search for tracks, your code will contain functions to make requests to every endpoint in the Web API. This only grows over time. What if we could just have code for the endpoints we use?

How

I have been using ES2015 these last weeks and I enjoyed both its syntax and the way to export certain parts of a module. By using tree-shaking (some people prefer calling this dead code elimination) we can generate a bundle that only contains the imported functions for the needed endpoints. Even better, exported functions names can be further optimised thanks to mangling.

Last, but not least, by decoupling the request configurator from the actual function that makes the request we gain testability and flexibility. First, because most of the functions not need to mock the XMLHttpRequest or equivalent object. Second, because the consumer of the API can always provide a different "request maker" with custom logic to trigger some events, refresh tokens, log some info, etc.

Draft

Here is a draft of the concept. First, we split the functions that contain information about how to configure a request to several files, grouped by logic units:

// search.js
export const searchTrack = (req, query) =>
  req.build()
      .withUri('/search')
      .addQueryParameters({
        type: 'track',
        query
      })
      .send();

export const searchAlbum = (req, query) =>
  req.build()
      .withUri('/search')
      .addQueryParameters({
        type: 'album',
        query
      })
      .send();

export const searchArtist = (req, query) =>
  req.build()
      .withUri('/search')
      .addQueryParameters({
        type: 'artist',
        query
      })
      .send();
// playlist.js
export const getPlaylist = (req, userId, playlistId, options) =>
  req.build()
      .withUri(`${userId}'/playlists/${playlistId}`)
      .addQueryParameters(options);

export const createPlaylist = (req, userId, options) =>
  req.build()
      .withMethod('POST')
      .withUri(`${userId}'/playlists`)
      .addQueryParameters(options)
      .send();

Note also that ES2015 makes the syntax quite compact too. All these requests return a Promise. The support for callbacks is nice, but complicates the code and by looking at how people were using the other wrappers it is clear that Promises are preferred.

By exporting each function instead of a big object with lot of functions, we can use tree-shaking to get rid off the unused functions. Webpack 2 and Rollup support this feature, and you can see an example with the above code on Rollup.

The last bit needed is what is going to create requests, and perform them:

// requestBuilder.js
export default class {
  constructor() {
    this.baseApiHost = 'https://api.spotify.com/v1'
  }
  setAccessToken() {}
  ...
  build() {}
}

// request.js
export default class {
  constructor() {}
  ...
  withMetod() {}
  withUri() {}
  addQueryParameters() {}
}

The requestBuilder contains information about the base url for the API endpoints, as well as data related with the user's session. This can be the access token, but also refresh token if we have logic to refresh it when it expires. The request object is configured both using the requestBuilder and the information provided by the function that maps the endpoint.

The request can be swapped with XMLHttpRequest wrapped with a Promise, fetch() or any other request library as long as they configure the request, make it, and return a Promise.