Basic REST API for sending requests to the server using Axios

Contents

Background

Initially, I used an api that was pulled from old projects, I did not try to figure out how it works and just used what I had. It was large (130 lines) and had a lot of unnecessary logic, where the DRY principle was also not respected. It bothered me more and more - I wanted the code to look beautiful, and I could simply copy it to all new projects and instantly be able to send requests to the server.
I set out to figure out how to implement it more correctly. I was looking for information, templates, but failed: I did not find any articles where one whole api would be formed. So I decided to share my api that I wrote for my reusable repository template.

axios.defaults.validateStatus = status => status >= 200 && status <= 399;

To start

You have a project under development, it's time to communicate with the server. It all starts with installing dependencies:
To work with requests, we will use "axios".
To work with cookies, I use "cookies-next", but you can write your own handlers, or use any library you like.
I also use "react-toastify" to display notifications.
After installing all the necessary dependencies, you can start writing api in a separate file.
At the beginning I set up axios. I add a range of response codes that will return a positive response.

axios.defaults.validateStatus = status => status >= 200 && status <= 399;

interceptors.response

In axios, there are interceptors (interceptors) with which we can "intercept" requests or responses before they are processed. That is, before the answer gets to the place from which you used the api, you can do some transformations. And before the request flies to the server, you can do something with it, for example, add the necessary headers.

axios.interceptors.response.use(
 response => response,
 error => {
   if (error.response) {
     const {
       status,
       data: { errors = null, message = null },
     } = error.response;
     if (typeof window !== 'undefined') {
       message && toast.error(message);
       status === 401 && deleteCookie(AUTH_TOKEN);
     }
     console.error('Axios response error', error.response);
     return Promise.reject({ status, errors, message });
   } else if (error.request) {
     console.error('Axios request error', error.request);
     const { status, statusText } = error.request;
     return Promise.reject({ status, message: statusText });
   } else {
     console.error('Axios undefined error', error.message);
     return Promise.reject({ message: error.message });
   }
 }
);
When we access an interceptor, we have two callbacks. The first is triggered if the request was successful, the second - in the opposite situation.
The first callback is suitable if you need to add some kind of general adapter (mapper), for example, to convert received server data written in snake_case to camelCase for all requests.
You can also do something for a particular request based on the responseURL received in the object. It is also thrown into this callback. But I do not recommend doing this: it is better to put such logic into separate services for each request.
In my case, nothing needs to be added. Accordingly, I simply return the data in the form in which they came to me.
In the second callback, we are interested in response and request in the error object. Returns data in response if the request was made and the server responded with a status code that is outside the range set at the beginning. In a request , data is returned if a request was made but no response was received, for example, if we received a CORS error. If there is no data either there or there, we understand that an error occurred while setting up the request.
On our server, in case of errors, for example, in the same validation, the error is returned as an array of errors and message with the general error text. It is these two keys that I destructure from data in the first if block and return to the place where the request was called. The data will store the data that YOUR server returns to you.
Focusing on the existing response code, we can write different situational logic. For example, remove the token from the cookie with a 401 code (Unauthorized) or perform an additional request to refresh the token. Specifically in this case, if the server returns a message, I output it to toast.
In such a situation, it is necessary to agree on the shore that the correct message will always be sent to us from the server. I'm doing the typeof window !== 'undefined' check because the project is written in Next.js and most requests are made on the server side. There we don't have a browser api and we can't access the same cookies. If you are writing a React project, this check is not needed.
In error.request , we return the data we need, namely the status, code and text message.
If the error is not defined, return the error message, and output the entire error object to the console. However, I print it in its entirety in all cases, as you may have noticed.

interceptors.request


axios.interceptors.request.use(
 config => {
   config.headers.Accept = 'application/json';
   if (typeof window !== 'undefined') {
     config.headers.Authorization = `Bearer ${getCookie(AUTH_TOKEN)}`;
   }
   return config;
 },
 error => {
   console.error(error);
   return Promise.reject(error);
 }
);
Everything is simple here. I am adding the required headers to all requests. I use the authorization token if the request is made on the client. As I wrote earlier, on the server, we cannot access cookies in this way. When a request is made on the server, the token will be passed to the headers from the page initialization function. In case of an error, we return the error.
Then we write the base function to execute queries and the query functions that will use it.

const makeBaseRequest =
 (method: REQUEST_METHODS): BaseRequestReturnType =>
 async (config: BaseRequestParams) => {
   return axios({
     method,
     ...config,
   });
 };
The makeBaseRequest function can be written in many ways, but I showed the most concise one. However, it may not be obvious to everyone, so I will show two more options.

const makeBaseRequest =
 (method: REQUEST_METHODS): BaseRequestReturnType =>
 async ({ url, data, headers, params }: BaseRequestParams) => {
   return axios({
     url,
     method,
     data,
     headers,
     params,
   });
 };

const makeBaseRequest =
 (method: REQUEST_METHODS): BaseRequestReturnType =>
 async ({ url, data, headers, params }: BaseRequestParams) => {
   switch (method) {
     case REQUEST_METHODS.GET:
       return axios.get(url, { headers, params });
     case REQUEST_METHODS.POST:
       return axios.post(url, data, { headers, params });
     case REQUEST_METHODS.PUT:
       return axios.put(url, data, { headers, params });
     case REQUEST_METHODS.PATCH:
       return axios.patch(url, data, { headers, params });
     case REQUEST_METHODS.DELETE:
       return axios.delete(url, { headers, params });
     default:
       throw new Error(`Invalid request method ${method}.`);
   }
 };
Our base API is ready. It consists of only 66 lines and is fully typed. You can expand it based on the needs of the project and your own preferences. I'm used to writing a separate service for each request. It might look like this:

interface Params {
 id: number;
 userName: string;
 tasksAmount: number;
}


const getTodosData = async ({ id, userName, tasksAmount }: Params): Promise> => {
 const payload = {
   id,
   user_name: userName,
   tasks_amount: tasksAmount,
 };
 try {
   const { data: json } = await post({ url: TODOS_ENDPOINT, data: payload });
   const data = mapJsonToTodoType(json);


   return { ok: true, data };
 } catch (error) {
   return Promise.reject(error);
 }
};

export default getTodosData;
In the service, you can write any logic related to your request. Various transformations, adapters, working with FormData and so on.

api typisation

I described the request configuration passed to axios as follows:

import type { AxiosRequestConfig, RawAxiosRequestHeaders } from 'axios';

type AxiosDataType = Record | FormData;

export type BaseRequestParams = {
 url: string;
 data?: AxiosDataType;
 headers?: RawAxiosRequestHeaders;
 params?: AxiosRequestConfig['params'];
};
There is not much to comment here, which cannot be said about the return type of the makeBaseRequest function.

export type BaseRequestReturnType = <T>(params: BaseRequestParams) => Promise<AxiosResponse<T>>;
This type is built using a generic, which allows us to dynamically define the return value.
Thus, in my service, I specify the type that my request will return to me in case of successful execution.
 const { data: json } = await get<TodoJson>({ url: TODOS_ENDPOINT });

Custom return key with request status

Is it really necessary to use it, and for what purpose is it done at all?
On old projects, I noticed that developers from the server in each request send in response, among other things - { status: 'success' | 'failed' } or { success: boolean }.
A front-end developer can navigate by this key, understanding whether the request was successful or not.
Here is just an example image, this code does not need to be copied, so I leave the screen
As for me, this is just extra logic that bloats your code. We can receive a response code and use it to determine how successful the request was. Moreover, axios automatically returns an error after our settings if the response code is out of range.
Accordingly, further logic goes into the catch block. If the request is successful, the code will continue to run in the try block. Thus, we are sure that the request was completed successfully.
Why then send an additional status from the server and why use it in the code? You can create your own key in your service that will report that everything went as expected rather than requesting it from the server - see my getTodosData service above.
If you have tips and suggestions for optimizing api, or if you have come up with a more interesting option, I will be glad to hear your comments.