How to write the right API client in TypeScript
In this article, I will talk in detail about the implementation of the client API in TypeScript for working with both third-party APIs and my own.
Creating an application is more complicated than a to-do list, most often we need to interact with some data stored on the server. These can be weather forecasts processed by a third-party API, as well as our customertyps' data, be it their login and password or a shopping list in the store. Working with a SPA (Single Page Application) application, we need to receive, modify and send this very data from the client side. Therefore, you need to have some kind of layer responsible for interacting with the server. In this article, we will consider using the API client with the React library, although it can be safely used on the same Vue, Svelte, and so on.
Why not register all queries in the components where they are used?
It's simple: if you change the API interface you are working with, you will have to go through all the code and find all the points of change that it affected. You can try to put this logic into React hooks, since we are talking about it now, but this solution will not be able to be used in other projects with other frameworks.
Typescript implementation
To begin with, we will put the domains where the API is located in a kind of config that works with the .env
file:
REACT_APP_API_BASE_URL="http://localhost:8083"
export default {
get apiBaseUrl(): string {
return process.env.REACT_APP_API_BASE_URL || "";
},
}
Then we will write an abstract client itself, not tied to this domain. It will require the axios and axios-extensions libraries to work.
Client Code:
import axios, { AxiosInstance, AxiosRequestConfig } from "axios";
import {
Forbidden,
HttpError,
Unauthorized
} from '../errors';
import { Headers } from "../types";
export class ApiClient {
constructor(
private readonly baseUrl: string,
private readonly headers: Headers,
private readonly authToken: string = ""
) {}
public async get(endpoint: string = "", params?: any, signal?: AbortSignal): Promise<any> {
try {
const client = this.createClient(params);
const response = await client.get(endpoint, { signal });
return response.data;
} catch (error: any) {
this.handleError(error);
}
}
public async post(endpoint: string = "", data?: any, signal?: AbortSignal): Promise<any> {
try {
const client = this.createClient();
const response = await client.post(endpoint, data, { signal });
return response.data;
} catch (error) {
this.handleError(error);
}
}
public async uploadFile(endpoint: string = "", formData: FormData): Promise<any> {
try {
const client = this.createClient();
const response = await client.post(endpoint, formData, {
headers: {
"Content-Type": "multipart/form-data",
}
})
return response.data;
} catch (error) {
this.handleError(error);
}
}
private createClient(params: object = {}): AxiosInstance {
const config: AxiosRequestConfig = {
baseURL: this.baseUrl,
headers: this.headers,
params: params
}
if (this.authToken) {
config.headers = {
Authorization: `Bearer ${this.authToken}`,
}
}
return axios.create(config);
}
private handleError(error: any): never {
if (!error.response) {
throw new HttpError(error.message)
} else if (error.response.status === 401) {
throw new Unauthorized(error.response.data);
} else if (error.response.status === 403) {
throw new Forbidden(error.response.data);
} else {
throw error
}
}
}
The client uses custom types such as Headers
, which, in fact, is just a dictionary [key: string]: string, and various errors that inherit the global Error
class (Unauthorized, Forbidden, HTTPError), so that in the future it will be easier to understand what caused them.
The class has only three public methods, which generate an axios client every time they are used. This client can work with both public API endpoints and protected ones by adding a header with a Bearer token. How the client receives this very token will be discussed later. Both the get and post methods use the optional abort Signal
parameter, which allows you to interrupt the sending of the request depending on the user's actions.
In the case of sending any files to the server, the client uses the uploadFile()
method, sending a request to the server with the Content-Type: multipart/form-data header.
To encapsulate the logic of creating these clients, we will write a factory.
Factory Code:
import { Headers } from "../../types";
import { ApiClient } from "../../clients";
export class ApiClientFactory {
constructor(
private readonly baseUrl: string,
private readonly headers: Headers = {}
) {}
public createClient(): ApiClient {
return new ApiClient(this.baseUrl, this.headers);
}
public createAuthorizedClient(authToken: string): ApiClient {
return new ApiClient(this.baseUrl, this.headers, authToken);
}
}
It doesn't do anything complicated: it just creates either a regular client or an authorized one, passing a token to the constructor.
Specific implementation
Now we need to adapt this abstract client to some specific endpoint. For example, let's create a manager that receives the latest status of the user profile from the server:
import { ApiClientInterface } from "./clients";
import { Profile } from "./models";
export class ProfileManager {
constructor(private readonly apiClient: ApiClientInterface) {}
public async get(): Promise<Profile> {
return this.apiClient.get("");
}
}
In this example, we don't care about the model we use for the profile. Let's just assume that it is compatible with the value transmitted from the server.
The manager class itself uses composition and stores the client object in its state in order to forward all API requests to it, and if necessary, it can add some logic of its own to the received value (perform validation, create its own endpoint, and so on).
Most often, APIs group domain logic by adding a specific prefix to their endpoints. There are also cases of API migration from one version to a newer one. To provide for all this, we will create a factory for this particular manager.
Factory Code:
import { ApiClientFactory } from "./clients";
import { Headers } from "../types";
import { ProfileManager } from "../ProfileManager";
export class ProfileManagerFactory {
private readonly apiClientFactory: ApiClientFactory;
constructor(baseUrl: string, headers: Headers) {
this.apiClientFactory = new ApiClientFactory(
`${baseUrl}/api/v1/profile`,
headers
);
}
public createProfileManager(authToken: string): ProfileManager {
return new ProfileManager(
this.apiClientFactory.createAuthorizedClient(authToken)
);
}
}
When creating this factory, the domain URL and headers for the request are passed to the constructor. Then these parameters are passed to the client API factory constructor, adding the API version and the same prefix denoting part of the domain logic after the passed URL. When creating a user profile manager, authorization is required, so a token is passed to the method, on the basis of which a client with an authorization header is created.
Dependency injection
Now all that remains is to write a function that will be responsible for providing a working profile manager in any part of the code, be it a React component or an independent Typescript class. It will look something like this:
export async function createProfileManager(): Promise<apiClient.ProfileManager> {
const factory = new apiClient.ProfileManagerFactory(apiClientConfig.apiBaseUrl, getBaseHeaders());
return factory.createProfileManager(await getAuthToken());
}
First, a factory of these managers is created inside, to which the server domain and the base headers are transferred, which look like this:
function getBaseHeaders(): apiClient.Headers {
return {
"Accept-Language": "en"
}
}
If desired, you can add any of your own headers at the level of the manager creation function.
I will not discuss the method of obtaining an API token and the operation of the getAuthToken()
function in this article, because this topic deserves a separate publication.
async function getAuthToken(): Promise<string> {
// ЗThere would be a token receipt code here, but for now...
return localStorage.getItem("auth-token");
}
Использование в компонентах.
An example of the profile manager is presented below:
useEffect(() => {
(async () => {
try {
await initProfile();
} catch (error: any) {
await handleError(error);
} finally {
setLoading(false);
}
})()
}, []);
const initProfile = async () => {
const manager = await createProfileManager();
const profile = await manager.get();
await dispatch(set(profile));
}
When the function is run in the useEffect
hook, a profile manager is asynchronously created, which then requests the current status of the user profile from the server. In this example, we simply write the received state to the Redux storage, so that we can then work with this profile without re-requesting it from the server each time. In case of a client error, the handleError()
function is launched, which, depending on the type of error, as I mentioned earlier, performs certain actions.
Results
This implementation is independent of the framework you are working with, it can be used even on native JS (TS). There are a lot of things you can refine in it, for example, add a "Builder" pattern to create an API client and transfer parameters, abortsignals and other things to it, or make a variable authentication system via a JWT token. Everything is at your discretion). In the next article I will tell you about the method of obtaining and working with API tokens on the client.
Follow me on Github <3