🍉

Simple API layer instead of axios

March 01, 2023

While there are many tools for fetching data, with axios being the most popular, you don’t need a lot of complicated functionality for simple apps. There is a way to do it with fetch, a part of Web API and without adding another dependency to your project.

The goal is to create a wrapper, or a layer around fetch, to handle data, authorization and errors.

API class

Let’s start by creating an abstract API class. You can put it in a separate file inside your project. To make our API more generic and reusable in the future, we will accept base endpoint URL and other config parameters as arguments.

class Api {  
  constructor({ baseURL, config }) {  
    this.baseURL = baseURL;  
    this.headers = config.headers;  
  }  
}

Create API instance

Then we create an instance of our API and pass baseURL and config. You can pass headers that are going to be shared by all requests or add more data into the config if needed. For API I’ll be using fakeAPI.

export const fakeApi = new Api({  
  baseURL: "https://jsonplaceholder.typicode.com",  
  config: {  
    headers: {  
      "Content-Type": "application/json",  
    },  
  },  
});

Populate class with methods

Now we can put our first method inside a class.

getPost() {  
  return fetch(this.baseURL + "/posts/1", {  
    method: "GET",  
    headers: this.headers,  
  })  
    .then(this._handleResponse)  
    .catch(this._handleError);  
}

Let’s add another method for creating a post. For this, we will need to accept data as an argument. We also change the HTTP method to POST.

createPost(data) {  
  return fetch(this.baseURL + "/posts", {  
    method: "POST",  
    headers: this.headers,  
    body: JSON.stringify(data),  
  })  
    .then(this._handleResponse)  
    .catch(this._handleError);  
}

this._handleResponse and this._handleError are going to be our private class methods. We will not use them directly outside of the class.

Handle response and error

Fetch promise will be rejected only if there is a network error, or if something is preventing it from executing. In case we get an error from the server, even with 400 and 500 codes, promise will still be resolved normally. So we will get the info on the error based on the response body. Response.ok is set to true only if the server response status code is in the range 200-299.

So we return the result if response is ok, or reject promise with the error, which is later going to be caught inside this._handleError.

_handleResponse(res) {  
  if (res.ok) {  
    return res.json();  
  }  
  
  return Promise.reject(new Error(`${res.status} ${res.statusText}`));  
}

You can decide what to do with the error, for now, we will just log it.

_handleError(err) {  
  console.log(err);  
}

API overview

By now our class looks like this.

class Api {  
  constructor({ baseURL, config }) {  
    this.baseURL = baseURL;  
    this.headers = config.headers;  
  }  
  
  getPost() {  
    return fetch(this.baseURL + "/posts/1", {  
      method: "GET",  
      headers: this.headers,  
    })  
      .then(this._handleResponse)  
      .catch(this._handleError);  
  }  
  
  createPost(data) {  
    return fetch(this.baseURL + "/posts", {  
      method: "POST",  
      headers: this.headers,  
      body: JSON.stringify(data),  
    })  
      .then(this._handleResponse)  
      .catch(this._handleError);  
  }  
  
  _handleResponse(res) {  
    if (res.ok) {  
      return res.json();  
    }  
  
    return Promise.reject(new Error(`${res.status} ${res.statusText}`));  
  }  
  
  _handleError(err) {  
    console.log(err);  
  }  
}  
  
export const fakeApi = new Api({  
  baseURL: "https://jsonplaceholder.typicode.com",  
  config: {  
    headers: {  
      "Content-Type": "application/json",  
    },  
  },  
});

Using API

Now we can use it within our app. For example inside React’s useEffect.

import { fakeApi } from "./utils/api";  

// get post
fakeApi.getPost().then((data) => {  
  console.log(data);  
});  

// create new post
fakeApi  
  .createPost({  
    title: "New post title",  
    body: "Our post contents",  
  })  
  .then((data) => {  
    console.log(data);  
  });

Add authentification

Now we can take it a little bit further by adding methods, that will work with auth scenarios. fakeApi doesn’t provide JWT authorization endpoints, so you’ll have to use yours.

JWT authorization implies that the server provides the client (our frontend app) with access and refresh tokens. Access token is used in headers to gain access to private endpoints. Refresh token is used to get a new access token, in case the old one is expired or somehow damaged.

Refreshing access token

In our API class, we will add a new refreshToken method. This is used for getting a new access token from the server, assuming we have the refresh token in cookies or local storage. jsCookie could be an existing package or a custom function.

refreshToken() {  
  return fetch(this.endpoint + "/refresh-token", {  
    headers: {  
      "Content-Type": "application/json",  
    },  
    method: "POST",  
    body: JSON.stringify({  
    // token property would vary depending on the 
      token: jsCookie.get("refreshToken"),  
    }),  
  }).then(this._handleResponse);  
}

Fetch with refresh wrapper

Then we will make a wrapper function around fetch, _fetchWithRefresh. This is a private method that we will use inside our API class. It will handle a special case, when based on server error we know that our access token is damaged or missing. Response could vary on your server, for example it could be a response with status code 401 or 403, custom error message like 'jwt expired', or something else.

_fetchWithRefresh = async (url, options) => {  
  try {  
	// try the initial request, if it's ok, we just return the response
    const res = await fetch(url, options);  
    return await this._handleApiResponse(res);  
  } catch (err) {  
	// check if our error has anything to do with bad jwt
    if (err.message === "Auth required") {  
    
	  // make a refresh jwt request in order to get a new token
      const refreshData = await this.refreshToken();  

	  // if it ends with error we are just gonna pass it further
      if (!refreshData.success) {  
        return Promise.reject(refreshData);  
      }  

	  // if successful, we store the new token in cookies, 
	  // add it into Authorization  header and repeat the initial request
      this.setCookiesFromResponse(refreshData);  
      options.headers.Authorization = refreshData?.accessToken;  
  
      const res = await fetch(url, options);  
      return await this._handleApiResponse(res);  
    } else {  
	  // non jwt related error we just pass down the chain into a catch block
      return Promise.reject(err);  
    }  
  }  
};

Set cookies

Our cookie setting function can look like this.

setCookiesFromResponse = (res) => {
  // property names in server response may vary
  const { accessToken, refreshToken } = res;  
  
  if (accessToken) {
    jsCookie.set("accessToken", accessToken.substring(7), { expires: 1 });  
  }  
  
  if (refreshToken) {  
    jsCookie.set("refreshToken", refreshToken, { expires: 30 });  
  }  
};

Using our fetchWithRefresh

Then inside a method, that is supposed to do an auth required request, instead of the fetch, we return our _fetchWithRefresh. Our createPost will look like this now.

createPost(data) {  
  return this._fetchWithRefresh(this.baseURL + "/posts", {  
    headers: {  
      ...this.headers, 
      Authorization: `Bearer ` + jsCookie.get("accessToken"),  
    },  
    method: "POST",  
    body: JSON.stringify(data),  
  });  
}

We are not going to add .then(this._handleResponse), because it is already been handled inside _fetchWithRefresh.

Now we can invoke our post creating method the same way we did previously, passing just the data for the body. All the magic with authorization headers, setting and getting cookies, tokens refresh and error handling will be done in our API layer.

fakeApi  
  .createPost({  
    title: "New post title",  
    body: "Our post contents",  
  })  
  .then((data) => {  
    console.log(data);  
  });

Conclusion and scaling

The method described in this article is sufficient in most of the use cases for simple apps, that do not require something extra complicated. Also, keep in mind that all the endpoints URL’s are being constructed inside our API class, as we write separate methods for each endpoint. Or even more than one, in case the same endpoint can be used with different HTTP methods, like GET and POST.

This approach helps keep our API calls short and clean, like this.

fakeApi.getPost().then((data) => {  
    // ...
});  
  
fakeApi.createPost(postData).then((data) => {  
	// ... 
  });

But at the same time, it keeps hard for us to reuse our API class. If we use different APIs in our app, we might want to make the class more generic by extracting all the endpoints URL’s and headers, instead setting them in the moment of calling our API.

Then, our API class can look something like this.

class Api {  
  constructor({ endpoint, config, body }) {  
    this.config = {  
      ...config,  
      // add something else  
    }  
  
    if (body) {  
      config.body = JSON.stringify(body)  
    }  
  }  

	// ...
}

Comments

2022 — 2023 Made by Arbuznik with ♡️ and Gatsby