Every time I find myself connecting to a third-party API that doesn't have a client library I have to create a lot of boilerplate around it:
It was one of these days where I decided to connect to Dev.to API to automatically collect stats for my published articles. There wasn't any API client ready for use, so I ended up creating all of the wrapping logic around it. So if it's already there then why not share it with others together with my learnings, right? We'll go through five steps and come out with a clear plan on how to implement your own client.
Before jumping into the coding part, let's discuss which language we're going to use. Remember my point about defining data models? For API client it's essential to give a user more information about data that is going be returned, so we don't have to constantly switch context between documentation and IDE. It also helps to avoid bugs and typos as you write code (we all have tests in the end, haven't we?). Keeping all that in mind, the best choice at the moment is to use Typescript.
Now let's think where our library is going to be run. Basically, it could a browser or a Node.js server. Why not develop a library that works in both? It'll also make it easier to use it together with server-side rendering where code executed at the server first and then on the client.
Two major differentiators for any library is its size and support of users with old and modern clients. First, we want our size to be as small as possible - API client shouldn't add much weight to the project. Second, the library should have decent browser support by serving the CommonJS bundle for those who cannot support the modern version and at the same time give a modern bundle for clients with newer versions.
The default choice for a web project is Webpack, but our library is fairly small and simple, so I've chosen Rollup as base bundler together with
for easy setup.microbundle
npm i -D microbundle
Now update the
package.js
with build tasks and a path to entry point:{
"source": "src/foo.js", // Your source file (same as 1st arg to microbundle)
"main": "dist/foo.js", // output path for CommonJS/Node
"module": "dist/foo.module.js", // output path for JS Modules
"unpkg": "dist/foo.umd.js", // optional, for unpkg.com
"scripts": {
"build": "microbundle", // uses "source" and "main" as input and output paths by default
"dev": "microbundle watch"
}
}
As we indent to support a lot of API endpoints we want our code to scale well if API expands. One of the best ways to do that is to align folders with resource names. For Dev.to API it would look like this:
/src
/articles
index.ts // Everything that's related to articles
...
/comments
index.ts
...
/users
...
index.ts // Imports and joins all resources together
It also useful to keep resources as separate classes, so you don't need to change root
index.ts
every time you add a new method. Then you'd need to merge them together using Typescript Mixins.function applyMixins(derivedCtor: any, baseCtors: any[]) {
baseCtors.forEach(baseCtor => {
Object.getOwnPropertyNames(baseCtor.prototype).forEach(name => {
Object.defineProperty(
derivedCtor.prototype,
name,
Object.getOwnPropertyDescriptor(baseCtor.prototype, name)
);
});
});
}
class DevTo extends Base {}
interface DevTo extends Articles, Comments, Users {}
applyMixins(DevTo, [Articles, Comments, Users])
export default DevTo
We wanted to make a library that works both in the browser and in Node.js. How we would make our requests universal as well?
fetch
is available in the browser but missing in Node.js where you should use the http
module. isomorphic-unfetch
will help us use fetch function everywhere and switch between browser and Node.js versions automatically.request
function which wraps fetch and append authentication header:function request<T> (endpoint: string, options?: RequestInit): Promise<T> {
const url = this.basePath + endpoint
const headers = {
'api-key': this.apiKey,
'Content-type': 'application/json'
}
const config = {
...options,
headers,
}
return fetch(url, config).then(r => {
if (r.ok) {
return r.json()
}
throw new Error(r.statusText)
})
}
We always return
Promise
, so clients can chain requests together or wait for the results.Most of the code in API client is integration with third-party endpoints. To make testing easier we can utilize the Nock library that conveniently mocks HTTP server for us.
describe('Article resource', () => {
test('getArticles returns a list of articles', async () => {
// Set up the mock request
const scope = nock('https://dev.to/api/')
.get('/articles')
.reply(200, [{ title: 'Article' }])
// Make the request
const DevToClient = new DevTo({ apiKey: 'XYZ' })
await DevToClient.getArticles()
// Assert that the expected request was made.
scope.done()
})
})
In the example above we set up the mock, then make the request and finally checking that mock has been executed.
Together we designed an API client that is small, scalable, supports Typescript out-of-the-box, and works in browser and in Node.js.
If you want to see the final code, feel free to check out the repository. Don’t hesitate to ask me for the details or suggest an improvement.
Previously published at https://lyamkin.com/blog/how-to-build-api-client-library-in-js/