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 import
ed 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.