Simple API layer instead of axios
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