tutorial // Dec 10, 2021

How to Write an API Wrapper Using JavaScript Classes and Fetch

How to write an API wrapper using JavaScript classes that calls to the JSON Placeholder API using convenient, easy-to-remember methods via Fetch.

How to Write an API Wrapper Using JavaScript Classes and Fetch

Getting Started

For this tutorial, we're going to use CheatCode's full-stack JavaScript framework, Joystick. Joystick brings together a front-end UI framework with a Node.js back-end for building apps.

To begin, we'll want to install Joystick via NPM. Make sure you're using Node.js 16+ before installing to ensure compatibility (give this tutorial a read first if you need to learn how to install Node.js or run multiple versions on your computer):

Terminal

npm i -g @joystick.js/cli

This will install Joystick globally on your computer. Once installed, next, let's create a fresh project:

Terminal

joystick create app

After a few seconds, you will see a message logged out to cd into your new project and run joystick start:

Terminal

cd app && joystick start

After this, your app should be running and we're ready to get started.

Writing the API wrapper class

For this tutorial, we're going to write a wrapper for the JSON Placeholder API, a free HTTP REST API for testing. Our goal is to create a reusable "wrapper" that helps us to streamline the process of making requests to the API.

To begin, we're going to build out the API wrapper itself as a JavaScript class. This will give us a way to—if we wish—create multiple instances of our wrapper. Inside of the app we just created, let's open up the /api folder at the root of the project and create a new file at /api/jsonplaceholder/index.js:

/api/jsonplaceholder/index.js

class JSONPlaceholder {
  constructor() {
    this.endpoints = {};
  }
}

export default new JSONPlaceholder();

Creating a skeleton for our wrapper, here, we set up a basic JavaScript class with a constructor() function—what gets called immediately after the new keyword is called on a JavaScript class—that sets up an empty object on the class this.endpoints. Inside, as we progress, we'll build out this this.endpoints object to contain methods (functions defined on an object) for dynamically generating the HTTP requests we want our wrapper to perform.

At the bottom of our file, though we can technically just export the class itself (without the new keyword), here, for testing we're just going to create a single instance and export that as export default new JSONPlaceholder(). This will allow us to import and call to our wrapper directly from elsewhere in our app without having to do something like this first:

import JSONPlaceholder from 'api/jsonplaceholder/index.js';

const jsonPlaceholder = new JSONPlaceholder();

jsonPlaceholder.posts('list');

Instead, we'll just be able to do:

import jsonPlaceholder from './api/jsonplaceholder/index.js';

jsonPlaceholder.posts('list');

To see how we get to this point, next, let's build out that this.endpoints object in the constructor and explain how it will help us to perform requests.

/api/jsonplaceholder/index.js

import fetch from 'node-fetch';

class JSONPlaceholder {
  constructor() {
    this.endpoints = {
      posts: {
        list: (options = {}) => {
          return {
            method: 'GET',
            resource: `/posts${options.postId ? `/${options.postId}` : ''}`,
            params: {},
            body: null,
          };
        },
      },
    };
  }
}

export default new JSONPlaceholder();

By the time we finish our wrapper, our goal is to be able to call to an API endpoint like this: jsonPlaceholder.posts('list') and receive the response from the JSON Placeholder API without performing any extra steps.

To get there, we need a standardized way to generate the HTTP requests that we're going to perform. This is what we're doing above. We know that we'll potentially need four things in order to perform a request to the API:

  1. The HTTP method supported by the target endpoint (i.e., POST, GET, PUT, or DELETE).
  2. The resource or URL for the endpoint.
  3. Any optional or required query params.
  4. An optional or required HTTP body object.

Here, we create a template for specifying these four things. To keep our wrapper organized, on our this.endpoints object, we create another property posts which represents the API resource we want to generate a request template for. Nested under this, we assign functions to properties with names that describe what the HTTP request is doing, returning the template related to that task.

In the example above, we want to get a list of posts back. To do it, we need to create a template tells us to perform an HTTP GET request to the /posts URL in the JSON Placeholder API. Conditionally, too, we need to be able to pass the ID of a post to this endpoint like /posts/1 or /posts/23.

This is why we define our request template generators as functions. This allows us to—if need be—take in a set of options passed when the wrapper is called (e.g., here, we want to take in the ID of a post which we anticipate to be passed via options.postId).

In return from our function we get back an object which we can then use in our code later to perform the actual HTTP request. Real quick, let's build out the rest of our request template generators:

/api/jsonplaceholder/index.js

class JSONPlaceholder {
  constructor() {
    this.endpoints = {
      posts: {
        create: (options = {}) => {
          return {
            method: 'POST',
            resource: `/posts`,
            params: {},
            body: {
              ...options,
            },
          };
        },
        list: (options = {}) => {
          return {
            method: 'GET',
            resource: `/posts${options.postId ? `/${options.postId}` : ''}`,
            params: {},
            body: null,
          };
        },
        post: (options = {}) => {
          if (!options.postId) {
            throw new Error('A postId is required for the posts.post method.');
          }

          return {
            method: 'GET',
            resource: `/posts/${options.postId}`,
            params: {},
            body: null,
          };
        },
        comments: (options = {}) => {
          if (!options.postId) {
            throw new Error('A postId is required for the posts.comments method.');
          }

          return {
            method: 'GET',
            resource: `/posts/${options.postId}/comments`,
            params: {},
            body: null,
          };
        },
      },
    };
  }
}

export default new JSONPlaceholder();

Same exact pattern repeated, just for different endpoints and different purposes. For each endpoint that we want to support, under the this.endpoints.posts object, we add a function assigned to a convenient name, taking in a possible set of options and returning a request template as an object with four properties: method, resource, params, and body.

Pay close attention to how the templates vary based on the endpoint. Some use different methods while others have a body while others do not. This is what we meant by having a standardized template. They all return an object with the same shape, however, what they set on that object differs based on the requirements of the endpoint we're trying to access.

We also should call attention to the this.endpoints.posts.post template and the this.endpoints.posts.comments template. Here, we throw an error if options.postId is not defined as a post ID is required in order to fulfill the requirements of these endpoints.

Next, we need to put these objects to use. Remember, our goal is to get to the point where we can call jsonPlaceholder.posts('list') in our code and get back a list of posts. Let's extend our class a bit to include the .posts() part of that line and see how it makes use of our request templates.

/api/jsonplaceholder/index.js

class JSONPlaceholder {
  constructor() {
    this.endpoints = {
      posts: {
        create: (options = {}) => { ... },
        list: (options = {}) => { ... },
        post: (options = {}) => { ... },
        comments: (options = {}) => { ... },
      },
    };
  }

  posts(method = '', options = {}) {
    const existingEndpoint = this.endpoints.posts[method];

    if (existingEndpoint) {
      const endpoint = existingEndpoint(options);
      return this.request(endpoint);
    }
  }
}

export default new JSONPlaceholder();

This should make things a bit clearer. Here, we've added a method to our JSONPlaceholder class posts which accepts two arguments: method and options. The first, method, maps to one of our templates while the second, options, is where we can conditionally pass values for our endpoint (e.g., like we saw with the post ID earlier when defining our templates).

Looking at the body of that posts() method, we start by checking to see if this.endpoints.posts has a property with a name matching the passed method argument. For example, if method equals list the answer would be "yes," but if method equals pizza, it would not.

This is important. We don't want to try to call to code that doesn't exist. Using the variable existingEndpoint, if we get a value back in return as existingEndpoint (we expect this to be a function if a valid name is used), next, we want to call to that function to get back our request template object. Notice that when we call the function stored in existingEndpoint, we pass in the options object.

So that's clear, consider the following:

jsonPlaceholder.posts('list', { postId: '5' });

We call our wrapper passing a postId set to '5'.

const existingEndpoint = this.endpoints.posts['list'];

Next, because method was equal to list, we get back the this.endpoints.posts.list function.

(options = {}) => {
  return {
    method: 'GET',
    resource: `/posts${options.postId ? `/${options.postId}` : ''}`,
    params: {},
    body: null,
  };
}

Next, inside of that function, we see that options.postId is defined and embed it into the resource URL like /posts/5.

/api/jsonplaceholder/index.js

class JSONPlaceholder {
  constructor() {
    this.endpoints = {
      posts: {
        create: (options = {}) => { ... },
        list: (options = {}) => { ... },
        post: (options = {}) => { ... },
        comments: (options = {}) => { ... },
      },
    };
  }

  posts(method = '', options = {}) {
    const existingEndpoint = this.endpoints.posts[method];

    if (existingEndpoint) {
      const endpoint = existingEndpoint(options);
      return this.request(endpoint);
    }
  }
}

export default new JSONPlaceholder();

Finally, back in our posts() method, we expect to get back an endpoint which is the request template object we generated inside of this.endpoints.posts.list.

Next, just beneath this, we call to another method that we need to define: this.request(), passing in the endpoint object we received from this.endpoints.posts.list. Let's take a look at that function now and finish out our wrapper.

/api/jsonplaceholder/index.js

import fetch from 'node-fetch';

class JSONPlaceholder {
  constructor() {
    this.endpoints = {
      posts: {
        create: (options = {}) => { ... },
        list: (options = {}) => { ... },
        post: (options = {}) => { ... },
        comments: (options = {}) => { ... },
      },
    };
  }

  request(endpoint = {}) {
    return fetch(`https://jsonplaceholder.typicode.com${endpoint.resource}`, {
      method: endpoint?.method,
      body: endpoint?.body ? JSON.stringify(endpoint.body) : null,
    }).then(async (response) => {
      const data = await response.json();
      return data;
    }).catch((error) => {
      return error;
    });
  }

  posts(method = '', options = {}) {
    const existingEndpoint = this.endpoints.posts[method];

    if (existingEndpoint) {
      const endpoint = existingEndpoint(options);
      return this.request(endpoint);
    }
  }
}

export default new JSONPlaceholder();

Real quick, before we look at the new request() method, up top, notice that we've added an NPM package as a dependency: node-fetch. Let's install that in our app before we continue:

Terminal

npm i node-fetch

Next, let's look closer at this request() method:

/api/jsonplaceholder/index.js

import fetch from 'node-fetch';

class JSONPlaceholder {
  constructor() {
    this.endpoints = {
      posts: {
        create: (options = {}) => { ... },
        list: (options = {}) => { ... },
        post: (options = {}) => { ... },
        comments: (options = {}) => { ... },
      },
    };
  }

  request(endpoint = {}) {
    return fetch(`https://jsonplaceholder.typicode.com${endpoint.resource}`, {
      method: endpoint?.method,
      body: endpoint?.body ? JSON.stringify(endpoint.body) : null,
    }).then(async (response) => {
      const data = await response.json();
      return data;
    }).catch((error) => {
      return error;
    });
  }

  posts(method = '', options = {}) {
    const existingEndpoint = this.endpoints.posts[method];

    if (existingEndpoint) {
      const endpoint = existingEndpoint(options);
      return this.request(endpoint);
    }
  }
}

export default new JSONPlaceholder();

Now for the fun part. Inside of the request() method, our goal is to take in the request template object as endpoint and use that to tailor the HTTP request we make to the JSON Placeholder API.

Looking at that method, we return a call to the fetch method we're importing from the node-fetch package we just installed. To it, we pass the URL we want to make our HTTP request to. Here, the "base" URL for the API is https://jsonplaceholder.typicode.com. Using JavaScript string interpolation (denoted by the backticks we're using to define our string as opposed to single or double quotes), we combine that base URL with the endpoint.resource value of the template matching the call.

For example, if we called to jsonPlaceholder.posts('list') we'd expect the URL we pass to fetch() to be https://jsonplaceholder.typicode.com/posts. If we called to jsonPlaceholder.posts('list', { postId: '5' }), we'd expect that URL to be https://jsonplaceholder.typicode.com/posts/5.

Following this logic, after the URL, we pass an object to fetch() containing additional options for the request. Here, we make use of the .method property on the passed template and, conditionally, the .body property on the passed template. If .body is defined, we take the value it contains and pass it to JSON.stringify()—a built-in JavaScript function—to convert the object into a string (important as we can only pass a string value for the HTTP request body—not the raw object).

After this, on the end of our call to fetch() we chain a .then() callback function as we expect fetch() to return a JavaScript Promise. To .then() we pass our callback function, prepending the async keyword to tell JavaScript that "we'd like to use the await keyword for one of the functions we call inside of this function" (without this, JavaScript would throw an error saying await was a reserved keyword).

Taking the response passed to that callback function—this is the HTTP response from the JSON Placeholder API—we call to its .json() method, placing await in front as we expect response.json() to return a JavaScript Promise. We use .json() here because we want to convert the plain text HTTP response body we get back from the API into JSON data that we can use in our code.

Storing this result in the data variable, we return it from the .then() callback which will bubble back to the return statement in front of fetch() and then bubble up one more time back to the return statement in front of this.request() inside of the posts() method (where our call originated from). In turn, this means we expect to get our data to pop out like this:

const data = await jsonPlaceholder.posts('list');
console.log(data);
/*
[
  {
    "userId": 1,
    "id": 1,
    "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
    "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
  },
  {
    "userId": 1,
    "id": 2,
    "title": "qui est esse",
    "body": "est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla"
  },
  {
    "userId": 1,
    "id": 3,
    "title": "ea molestias quasi exercitationem repellat qui ipsa sit aut",
    "body": "et iusto sed quo iure\nvoluptatem occaecati omnis eligendi aut ad\nvoluptatem doloribus vel accusantium quis pariatur\nmolestiae porro eius odio et labore et velit aut"
  },
]
*/

That does it for our wrapper. Now, to see this in action, we're going to wire up some test routes that we can access via a web browser, calling to our wrapper to verify the responses.

Defining routes to test the wrapper

To test our API wrapper, now, we're going to wire up some routes in our own app which will call to the JSON Placeholder API via our wrapper and then display the data we get back in our browser.

/index.server.js

import node from "@joystick.js/node";
import api from "./api";
import jsonPlaceholder from "./api/jsonplaceholder";

node.app({
  api,
  routes: {
    "/": (req, res) => {
      res.render("ui/pages/index/index.js", {
        layout: "ui/layouts/app/index.js",
      });
    },
    "/posts/create": async (req, res) => {
      const post = await jsonPlaceholder.posts('create', { title: 'Testing Posts' });
      res.setHeader('Content-Type', 'application/json');
      res.send(JSON.stringify(post, null, 2));
    },
    "/posts": async (req, res) => {
      const posts = await jsonPlaceholder.posts('list');
      res.setHeader('Content-Type', 'application/json');
      res.send(JSON.stringify(posts, null, 2));
    },
    "/posts/:postId": async (req, res) => {
      const post = await jsonPlaceholder.posts('post', { postId: req?.params?.postId });
      res.setHeader('Content-Type', 'application/json');
      res.send(JSON.stringify(post, null, 2));
    },
    "/posts/:postId/comments": async (req, res) => {
      const comments = await jsonPlaceholder.posts('comments', { postId: req?.params?.postId });
      res.setHeader('Content-Type', 'application/json');
      res.send(JSON.stringify(comments, null, 2));
    },
    "*": (req, res) => {
      res.render("ui/pages/error/index.js", {
        layout: "ui/layouts/app/index.js",
        props: {
          statusCode: 404,
        },
      });
    },
  },
});

This may seem overwhelming but look close. Inside of our app, when we ran joystick create earlier, an index.server.js file was set up for us where the Node.js server for our app is started. In that file, node.app() sets up an Express.js server behind the scenes and takes the routes object we pass it to dynamically generate Express.js routes.

Here, we've added some test routes to that object with each corresponding to one of the methods in our API wrapper. Also, at the top of index.server.js, we've imported our jsonPlaceholder wrapper (remember, we expect this to be a pre-initialized instance of our JSONPlaceholder class).

Focusing on our routes, starting with /posts/create, here, we begin by passing a function representing our route handler with the async keyword prepended (again, this tells JavaScript that we'd like to make use of the await keyword inside of the function that follows that declaration).

Here, we create a variable post set equal to a call to await jsonPlaceholder.posts('create', { title: 'Testing Posts' }). As we just learned, if all is working well we expect this to generate the template for our HTTP request to the JSON Placeholder API and then perform the request via fetch(), returning us the .json() parsed data from the response. Here, we store that response as post and then do two things:

  1. Set the HTTP Content-Type header on the response to our Express.js route to application/json to signify to our browser that the content we're sending it is JSON data.
  2. Responding to the request to our route with a stringified version of our posts response (formatted to use two tabs/spaces).

If we open up a web browser, we should see something like this when visiting http://localhost:2600/posts/create:

XGScoKWh6ZB1MseA/Dy141ToLOvRyXCC3.0
Rendering the response from JSON Placeholder API in the browser.

Cool, right? This works as if we wrote all of the code to perform a fetch() request inside of our route handler function but it only took us one line of code to make the call!

If we look close at our routes above, all of them work roughly the same. Notice the variation between each route and how that changes our call to jsonPlaceholder.posts(). For example, looking at the /posts/:postId/comments route, here we utilize the comments method we wired up which requires a postId passed in the options object of our wrapper call. To pass it, here, we pull the postId from the parameters of our route and pass it to the wrapper's options object as postId. In return we get back the comments for the post corresponding to the ID we specify in our URL:

XGScoKWh6ZB1MseA/NsBrbYnVRrsL2PnG.0
Viewing the response to our /posts/:postId/comments route.

Awesome. Real quick, let's do a live run through of all of our routes before we give this our stamp of approval:

And there we have it. A fully functional API wrapper. What's great about this pattern is that we can apply it to any HTTP or REST API that we'd like to standardize the use of.

Wrapping up

In this tutorial, we learned how to build an API wrapper using a Javascript class. We wrote our wrapper for the JSON Placeholder API, learning how to use a template-based approach for generating requests and leveraging a single function to perform that request via fetch(). We also learned how to define resource-specific methods on our class to make our wrapper extensible and easy to use.

Written By
Ryan Glover

Ryan Glover

CEO/CTO @ CheatCode