DEV Community

Cover image for Purest
simo
simo

Posted on • Updated on

Purest

The last REST API client library that you will ever need

Countless services on the Internet are being exposed over REST API. Most if not all REST API service providers have client libraries for various programming languages to interface with their API.

While all of that is nice, that also means that for every REST API service provider we have to learn a new API interface of that particular client library.

And if that's not the worst, then what if we have to interface with multiple REST API service providers using multiple REST API client libraries in a single code base?

It becomes a mess

The reason why is because we are dealing with client libraries that were never designed to interoperate between each other, even though they are doing roughly the same operations under the hood. The solution to this is to go one layer below and create the client library ourselves.

But how?

Purest is a generic REST API client library for building REST API client libraries. It's a tool for abstracting out REST APIs.

Introduction

Lets take a look at some basic configuration for Google:

{
  "google": {
    "default": {
      "origin": "https://www.googleapis.com",
      "path": "{path}",
      "headers": {
        "authorization": "Bearer {auth}"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

With it we can instantiate that provider:

var google = purest({provider: 'google', config})
Enter fullscreen mode Exit fullscreen mode

Then we can request some data from YouTube:

var {res, body} = await google
  .get('youtube/v3/channels')
  .qs({forUsername: 'GitHub'})
  .auth(token)
  .request()
Enter fullscreen mode Exit fullscreen mode

The above example demonstrates how a REST API provider can be configured and used in Purest by accessing its default endpoint.

Lets have a look at another example:

{
  "google": {
    "default": {
      "origin": "https://www.googleapis.com",
      "path": "{path}",
      "headers": {
        "authorization": "Bearer {auth}"
      }
    },
    "youtube": {
      "origin": "https://www.googleapis.com",
      "path": "youtube/{version}/{path}",
      "version": "v3",
      "headers": {
        "authorization": "Bearer {auth}"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This time around we have an explicit endpoint called youtube for accessing YouTube only:

var {res, body} = await google('youtube')
  .get('channels')
  .qs({forUsername: 'GitHub'})
  .auth(token)
  .request()
Enter fullscreen mode Exit fullscreen mode

The provider configuration is just a convenience for extracting out the request options that we don't want to specify for every request. The auth method is used for replacing the {auth} token found in your configuration, get is the HTTP method being used and its value is the substitute for the {path} token. The qs method is sort of a convention for naming a querystring object that is then being encoded and appended to the request URL.

The above request results in:

GET https://www.googleapis.com/youtube/v3/channels?forUsername=GitHub
authorization: Bearer access_token
Enter fullscreen mode Exit fullscreen mode

What else?

So far we have used Purest like this:

var google = purest({provider: 'google', config})
Enter fullscreen mode Exit fullscreen mode

This allows us to have a configuration and a provider instance for it. Any other dynamic option that is needed for the request have to be passed for every request.

Sometimes, however, we may want to configure certain dynamic values per instance:

var google = purest({provider: 'google', config,
  defaults: {auth: token}
})
Enter fullscreen mode Exit fullscreen mode

Then we no longer need to set the access token for every request:

var {res, body} = await google('youtube')
  .get('channels')
  .qs({forUsername: 'GitHub'})
  .request()
Enter fullscreen mode Exit fullscreen mode

Cool, but what if we want to make our API more expressive?

What if we want to make it our own?

var google = purest({provider: 'google', config,
  defaults: {auth: token},
  methods: {get: ['select'], qs: ['where']}
})
Enter fullscreen mode Exit fullscreen mode

Yes we can:

var {res, body} = await google('youtube')
  .select('channels')
  .where({forUsername: 'GitHub'})
  .request()
Enter fullscreen mode Exit fullscreen mode

Every method in Purest can have multiple user defined aliases for it.

Lastly, accessing an endpoint defined in your configuration can be done by using the explicit endpoint method or its default alias called query:

var {res, body} = await google
  .query('youtube')
  .select('channels')
  .where({forUsername: 'GitHub'})
  .request()
Enter fullscreen mode Exit fullscreen mode

Congratulations!

Now you know the basics.

But the possibilities are endless ...

Lets have a look at another example.

Refresh Token

One very common thing to do when working with REST API providers is to refresh your access token from time to time:

{
  "twitch": {
    "oauth": {
      "origin": "https://api.twitch.tv",
      "path": "kraken/oauth2/{path}"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Using the above configuration and the default aliases defined in Purest we can refresh the access token like this:

var {res, body} = await twitch
  .query('oauth')
  .update('token')
  .form({
    grant_type: 'refresh_token',
    client_id: '...',
    client_secret: '...',
    refresh_token: '...'
  })
  .request()
Enter fullscreen mode Exit fullscreen mode

Again query is just an alias for the endpoint method used to access the oauth endpoint in your configuration. The update method is an alias for post and 'token' replaces the {path} in the path configuration. The form method is sort of a convention for naming application/x-www-form-urlencoded request body object that then is being encoded as request body string.

The above request results in:

POST https://api.twitch.tv/kraken/oauth2/token
content-type: application/x-www-form-urlencoded

grant_type=refresh_token&client_id=...&client_secret=...&refresh_token=...
Enter fullscreen mode Exit fullscreen mode

Kinda nice!

Alright, but lets take a look at something more practical:

{
  "twitch": {
    "refresh": {
      "origin": "https://api.twitch.tv",
      "path": "kraken/oauth2/token",
      "method": "POST",
      "form": {
        "grant_type": "refresh_token",
        "refresh_token": "{auth}"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Then we can set the application credentials for the entire instance:

var twitch = purest({provider: 'twitch', config, defaults: {
  form: {
    client_id: '...',
    client_secret: '...'
  }
}})
Enter fullscreen mode Exit fullscreen mode

And refresh the access token like this:

var {res, body} = await twitch('refresh')
  .auth('the-refresh-token')
  .request()
Enter fullscreen mode Exit fullscreen mode

Each one of your users will have their own refresh_token, but most likely all of them will be authenticated using a single OAuth application. So it makes sense to configure the provider to use your app credentials by default and only supply the refresh token on every request.

OpenID Connect

OpenID Connect is a popular framework for user authentication and user identity.

One very common theme about it is verifying your JSON Web Token (JWT) that can be either access_token or id_token:

{
  "auth0": {
    "discovery": {
      "origin": "https://{subdomain}.auth0.com",
      "path": ".well-known/openid-configuration"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The above configuration is about the discovery endpoint of Auth0 that contains a JSON document outlining certain settings being set for that tenant. The {subdomain} is your tenant name or tenant.region where region applies:

var auth0 = purest({provider: 'auth0', config,
  defaults: {subdomain: tenant}
})

var {body:doc} = await auth0('discovery').request()
var {body:jwk} = await auth0.get(doc.jwks_uri).request()
Enter fullscreen mode Exit fullscreen mode

We request the discovery endpoint and store that document as the doc variable. Then we request the absolute jwks_uri returned in that JSON document and store it as jwk variable. The jwks_uri endpoint returns yet another JSON document containing a list of public keys that can be used to verify a token issued from that tenant:

var jws = require('jws')
var pem = require('jwk-to-pem')

var jwt = jws.decode('id_token or access_token')
var key = jwk.keys.find(({kid}) => kid === jwt.header.kid)

var valid = jws.verify(
  'id_token or access_token', jwt.header.alg, pem(key)
)
Enter fullscreen mode Exit fullscreen mode

We use two additional third-party modules to decode the JSON Web Token, find the corresponding key id (kid), and then verify that token by converting the public key to a PEM format.

OAuth 1.0a

Last but not least

Some providers are still using OAuth 1.0a for authorization. One popular provider that comes to mind is Twitter:

{
  "twitter": {
    "default": {
      "origin": "https://api.twitter.com",
      "path": "{version}/{path}.{type}",
      "version": "1.1",
      "type": "json",
      "oauth": {
        "token": "$auth",
        "token_secret": "$auth"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

For convenience we set the application credentials for the entire instance:

var twitter = purest({provider: 'twitter', config, defaults: {
  oauth: {
    consumer_key: '...',
    consumer_secret: '...'
  }
}})
Enter fullscreen mode Exit fullscreen mode

And then we pass the user's token and secret with every request:

var {res, body} = await twitter
  .get('users/show')
  .qs({screen_name: 'github'})
  .auth('...', '...')
  .request()
Enter fullscreen mode Exit fullscreen mode

That works, but having to remember all those weird configuration key names every time is hard. Why not put all of them in the default endpoint configuration once and forget about them:

{
  "twitter": {
    "default": {
      "origin": "https://api.twitter.com",
      "path": "{version}/{path}.{type}",
      "version": "1.1",
      "type": "json",
      "oauth": {
        "consumer_key": "{auth}",
        "consumer_secret": "{auth}",
        "token": "{auth}",
        "token_secret": "{auth}"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Then all we need to do is pass them as array of strings:

var twitter = purest({provider: 'twitter', config, defaults: {
  auth: ['...', '...', '...', '...']
}})
Enter fullscreen mode Exit fullscreen mode

And focus only on what's important:

var {res, body} = await twitter
  .get('users/show')
  .qs({screen_name: 'github'})
  .request()
Enter fullscreen mode Exit fullscreen mode

Streaming and Multipart

Lets upload some files:

{
  "box": {
    "upload": {
      "method": "POST",
      "url": "https://upload.box.com/api/2.0/files/content",
      "headers": {
        "authorization": "Bearer {auth}"
      }
    }
  },
  "drive": {
    "upload": {
      "method": "POST",
      "url": "https://www.googleapis.com/upload/drive/v3/files",
      "headers": {
        "authorization": "Bearer {auth}"
      }
    }
  },
  "dropbox": {
    "upload": {
      "method": "POST",
      "url": "https://content.dropboxapi.com/2/files/upload",
      "headers": {
        "authorization": "Bearer {auth}",
        "content-type": "application/octet-stream"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

As usual we have to instantiate our providers:

var box = purest({provider: 'box', config, defaults: {auth: token}})
var drive = purest({provider: 'drive', config, defaults: {auth: token}})
var dropbox = purest({provider: 'dropbox', config, defaults: {auth: token}})
Enter fullscreen mode Exit fullscreen mode

The file upload endpoint for Box expects a multipart/form-data encoded request body:

var {res, body} = await box('upload')
  .multipart({
    attributes: JSON.stringify({
      name: 'cat.png',
      parent: {id: 0},
    }),
    file: fs.createReadStream('cat.png')
  })
  .request()
Enter fullscreen mode Exit fullscreen mode

This is a common way for transferring binary files over the Internet. Every time you submit a Web form that allows you to pick a file from your local file system, the browser is then encoding that data as multipart/form-data, which is what the multipart method does when an object is passed to it.

We are also using the default fs module found in Node.js to stream that cat photo. Imagine that being a really big and fluffy cat that also happens to weight a lot of megabytes.

This is how we upload our cat photos to Google Drive instead:

var {res, body} = await drive('upload')
  .multipart([
    {
      'Content-Type': 'application/json',
      body: JSON.stringify({name: 'cat.png'})
    },
    {
      'Content-Type': 'image/png',
      body: fs.createReadStream('cat.png')
    }
  ])
  .request()
Enter fullscreen mode Exit fullscreen mode

Note that we are still using the multipart method, but this time around we are passing an array instead. In that case the request body will be encoded as multipart/related, which is yet another way to encode multipart request bodies. You can read more about that endpoint here.

Lastly to upload our cat photo to DropBox we stream it as raw request body:

var {res, body} = await dropbox('upload')
  .headers({
    'Dropbox-API-Arg': JSON.stringify({path: '/cat.png'}),
  })
  .body(fs.createReadStream('cat.png'))
  .request()
Enter fullscreen mode Exit fullscreen mode

No additional encoding is expected for the upload endpoint in DropBox.

Great!

But lets do something a bit more dynamic:

{
  "box": {
    "upload": {
      "method": "POST",
      "url": "https://upload.box.com/api/2.0/files/content",
      "headers": {
        "authorization": "Bearer {auth}"
      }
    }
  },
  "dropbox": {
    "download": {
      "url": "https://content.dropboxapi.com/2/files/download",
      "headers": {
        "authorization": "Bearer {auth}"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
var {res:download} = await dropbox('download')
  .headers({
    'Dropbox-API-Arg': JSON.stringify({path: '/cat.png'}),
  })
  .stream()

await box('upload')
  .multipart({
    attributes: JSON.stringify({
      name: 'cat.png',
      parent: {id: 0},
    }),
    file: {
      body: download,
      options: {name: 'cat.png', type: 'image/png'}
    }
  })
  .request()
Enter fullscreen mode Exit fullscreen mode

We are making the download request using the .stream() method. This instructs Purest to return the raw response stream.

Then we are piping the response stream from DropBox to the request stream for Box by passing it to the multipart file key. This time, however, we need to pass a few additional options because Purest cannot reliably determine the file name and the mime type to embed into the multipart body.

Conclusion

Simple problems need simple solutions

Purest allows us to go one layer below and elegantly compose our own REST API client.

Purest is a tool for creating abstractions without having to create one.

Purest is a primitive for writing HTTP clients.

Happy Coding!

Top comments (0)